From 7881ce22b4a184dff941c1d5fc7147f4d827ce10 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Wed, 18 Jan 2023 05:34:54 -0500 Subject: [PATCH] Improve design and usability, general tweaks and fixes (#226) See #226 for more details. --- assets/icons/TwitchGlitchWhite.png | Bin 41204 -> 0 bytes lib/main.dart | 38 +- lib/models/irc.dart | 184 ++--- lib/screens/channel/channel.dart | 269 ++++--- lib/screens/channel/chat/chat.dart | 93 ++- .../channel/chat/details/chat_details.dart | 230 ++++-- .../channel/chat/details/chat_modes.dart | 28 +- .../channel/chat/details/chat_users_list.dart | 83 +- .../channel/chat/emote_menu/emote_menu.dart | 136 ---- .../chat/emote_menu/emote_menu_panel.dart | 55 +- .../chat/emote_menu/emote_menu_section.dart | 32 +- .../chat/emote_menu/recent_emotes_panel.dart | 82 +- .../chat/stores/chat_assets_store.dart | 29 +- .../chat/stores/chat_assets_store.g.dart | 17 - .../channel/chat/stores/chat_store.dart | 75 +- .../channel/chat/stores/chat_store.g.dart | 73 ++ .../channel/chat/widgets/chat_bottom_bar.dart | 57 +- .../channel/chat/widgets/chat_message.dart | 21 +- .../channel/chat/widgets/chat_user_modal.dart | 38 +- lib/screens/channel/video/video_bar.dart | 107 +++ lib/screens/channel/video/video_overlay.dart | 389 ++++------ lib/screens/channel/video/video_store.dart | 74 +- lib/screens/channel/video/video_store.g.dart | 84 -- lib/screens/home/home.dart | 51 +- lib/screens/home/home_store.dart | 20 +- lib/screens/home/search/search.dart | 33 +- .../search/search_results_categories.dart | 10 +- .../home/search/search_results_channels.dart | 131 ++-- .../home/stream_list/large_stream_card.dart | 172 +++++ lib/screens/home/stream_list/stream_card.dart | 147 ++-- .../home/stream_list/streams_list.dart | 31 +- .../home/top/categories/categories.dart | 11 +- .../home/top/categories/category_card.dart | 15 +- .../home/top/categories/category_streams.dart | 49 +- lib/screens/home/top/top.dart | 23 +- .../settings/account/account_options.dart | 59 ++ .../settings/account/blocked_users.dart | 61 ++ .../account/widgets/profile_card.dart | 121 +++ lib/screens/settings/account_settings.dart | 265 ------- lib/screens/settings/chat_settings.dart | 463 ++++------- lib/screens/settings/general_settings.dart | 54 +- lib/screens/settings/other_settings.dart | 104 +-- lib/screens/settings/settings.dart | 42 +- lib/screens/settings/stores/auth_store.dart | 50 +- .../settings/stores/settings_store.dart | 255 +++--- .../settings/stores/settings_store.g.dart | 726 +++++++++--------- lib/screens/settings/video_settings.dart | 67 +- .../widgets/settings_list_select.dart | 72 ++ .../widgets/settings_list_slider.dart | 56 ++ .../widgets/settings_list_switch.dart | 28 + .../settings/widgets/settings_tile_route.dart | 35 + lib/widgets/alert_message.dart | 4 +- lib/widgets/animate_scale.dart | 4 +- lib/widgets/app_bar.dart | 31 + lib/widgets/block_button.dart | 82 -- lib/widgets/block_report_modal.dart | 54 +- lib/widgets/{modal.dart => bottom_sheet.dart} | 15 +- lib/widgets/button.dart | 10 +- lib/widgets/cached_image.dart | 44 ++ lib/widgets/dialog.dart | 19 +- lib/widgets/list_tile.dart | 32 + lib/widgets/notification.dart | 9 +- lib/widgets/page_view.dart | 79 ++ lib/widgets/profile_picture.dart | 33 +- lib/widgets/report_button.dart | 41 - lib/widgets/scroll_to_top_button.dart | 4 +- lib/widgets/section_header.dart | 29 +- pubspec.lock | 14 +- pubspec.yaml | 3 +- 69 files changed, 3040 insertions(+), 2812 deletions(-) delete mode 100644 assets/icons/TwitchGlitchWhite.png delete mode 100644 lib/screens/channel/chat/emote_menu/emote_menu.dart create mode 100644 lib/screens/channel/video/video_bar.dart create mode 100644 lib/screens/home/stream_list/large_stream_card.dart create mode 100644 lib/screens/settings/account/account_options.dart create mode 100644 lib/screens/settings/account/blocked_users.dart create mode 100644 lib/screens/settings/account/widgets/profile_card.dart delete mode 100644 lib/screens/settings/account_settings.dart create mode 100644 lib/screens/settings/widgets/settings_list_select.dart create mode 100644 lib/screens/settings/widgets/settings_list_slider.dart create mode 100644 lib/screens/settings/widgets/settings_list_switch.dart create mode 100644 lib/screens/settings/widgets/settings_tile_route.dart create mode 100644 lib/widgets/app_bar.dart delete mode 100644 lib/widgets/block_button.dart rename lib/widgets/{modal.dart => bottom_sheet.dart} (64%) create mode 100644 lib/widgets/cached_image.dart create mode 100644 lib/widgets/list_tile.dart create mode 100644 lib/widgets/page_view.dart delete mode 100644 lib/widgets/report_button.dart diff --git a/assets/icons/TwitchGlitchWhite.png b/assets/icons/TwitchGlitchWhite.png deleted file mode 100644 index 298d79bd1a28f089121f4284b172dca48d2d62cb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 41204 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 biS9lBTyWXw?{?JTwW$+lR%|*cwer6Jte*9D diff --git a/lib/main.dart b/lib/main.dart index 294cd811..53b0deef 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,7 +2,6 @@ import 'dart:convert'; import 'dart:io'; import 'package:device_preview/device_preview.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; @@ -61,7 +60,7 @@ void main() async { runApp( DevicePreview( - enabled: !kReleaseMode, + enabled: false, builder: (context) { return MultiProvider( providers: [ @@ -83,25 +82,44 @@ void main() async { final navigatorKey = GlobalKey(); const gray = Color.fromRGBO(18, 18, 18, 1.0); +const lightGray = Color.fromRGBO(28, 28, 28, 1.0); const purple = Color(0xff9146ff); const inputTheme = InputDecorationTheme( filled: true, contentPadding: EdgeInsets.all(10.0), border: OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(30.0)), + borderRadius: BorderRadius.all(Radius.circular(10.0)), borderSide: BorderSide(style: BorderStyle.none), ), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(30.0)), + borderRadius: BorderRadius.all(Radius.circular(10.0)), borderSide: BorderSide(style: BorderStyle.none), ), disabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(30.0)), + borderRadius: BorderRadius.all(Radius.circular(10.0)), borderSide: BorderSide(style: BorderStyle.none), ), ); +const tooltipTheme = TooltipThemeData( + padding: EdgeInsets.all(10.0), + margin: EdgeInsets.symmetric(horizontal: 5.0), + decoration: BoxDecoration( + color: lightGray, + borderRadius: BorderRadius.all(Radius.circular(10.0)), + ), + textStyle: TextStyle(color: Colors.white), +); + +const snackBarTheme = SnackBarThemeData( + backgroundColor: lightGray, + contentTextStyle: TextStyle(color: Colors.white), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(10.0)), + ), +); + final lightTheme = ThemeData( canvasColor: Colors.white, splashFactory: Platform.isIOS ? NoSplash.splashFactory : null, @@ -127,6 +145,8 @@ final lightTheme = ThemeData( unselectedLabelColor: Colors.grey, ), inputDecorationTheme: inputTheme, + tooltipTheme: tooltipTheme, + snackBarTheme: snackBarTheme, ); final darkTheme = ThemeData( @@ -152,6 +172,8 @@ final darkTheme = ThemeData( dialogBackgroundColor: gray, toggleableActiveColor: purple, inputDecorationTheme: inputTheme, + tooltipTheme: tooltipTheme, + snackBarTheme: snackBarTheme, ); final oledTheme = ThemeData( @@ -175,6 +197,8 @@ final oledTheme = ThemeData( dialogBackgroundColor: Colors.black, toggleableActiveColor: purple, inputDecorationTheme: inputTheme, + tooltipTheme: tooltipTheme, + snackBarTheme: snackBarTheme, ); class MyApp extends StatelessWidget { @@ -191,7 +215,9 @@ class MyApp extends StatelessWidget { locale: DevicePreview.locale(context), title: 'Frosty', theme: lightTheme, - darkTheme: settingsStore.themeType == ThemeType.dark || settingsStore.themeType == ThemeType.system ? darkTheme : oledTheme, + darkTheme: settingsStore.themeType == ThemeType.dark || settingsStore.themeType == ThemeType.system + ? darkTheme + : oledTheme, themeMode: settingsStore.themeType == ThemeType.system ? ThemeMode.system : settingsStore.themeType == ThemeType.light diff --git a/lib/models/irc.dart b/lib/models/irc.dart index e0f2a795..fd915d8d 100644 --- a/lib/models/irc.dart +++ b/lib/models/irc.dart @@ -1,4 +1,3 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:collection/collection.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -8,6 +7,7 @@ import 'package:frosty/models/badges.dart'; import 'package:frosty/models/emotes.dart'; import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; +import 'package:frosty/widgets/cached_image.dart'; import 'package:intl/intl.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -112,7 +112,7 @@ class IRCMessage { required double emoteScale, required bool isLightTheme, required bool launchExternal, - required void Function()? onLongPressName, + required void Function()? onTapName, bool showMessage = true, bool useReadableColors = false, TimestampType timestamp = TimestampType.disabled, @@ -174,7 +174,8 @@ class IRCMessage { badgeUrl = botBadge.url; skipBot = true; } else if (ffzRoomInfo?.modUrls != null) { - badgeUrl = 'https:${ffzRoomInfo!.modUrls?.url4x ?? ffzRoomInfo.modUrls?.url2x ?? ffzRoomInfo.modUrls!.url1x}'; + badgeUrl = + 'https:${ffzRoomInfo!.modUrls?.url4x ?? ffzRoomInfo.modUrls?.url2x ?? ffzRoomInfo.modUrls!.url1x}'; } final newBadge = Badge( @@ -196,7 +197,8 @@ class IRCMessage { // Add custom FFZ vip badge if it exists if (badgeInfo.name == 'VIP' && ffzRoomInfo?.vipBadge != null) { - badgeUrl = 'https:${ffzRoomInfo!.vipBadge?.url4x ?? ffzRoomInfo.vipBadge?.url2x ?? ffzRoomInfo.vipBadge!.url1x}'; + badgeUrl = + 'https:${ffzRoomInfo!.vipBadge?.url4x ?? ffzRoomInfo.vipBadge?.url2x ?? ffzRoomInfo.vipBadge!.url1x}'; } final newBadge = Badge( @@ -285,14 +287,15 @@ class IRCMessage { } // Add the display name (username) to the span and apply the onLongPressName callback. + final displayName = tags['display-name']!; span.add( TextSpan( - text: tags['display-name']!, + text: regexEnglish.hasMatch(displayName) ? displayName : '$displayName ($user)', style: TextStyle( color: color, fontWeight: FontWeight.bold, ), - recognizer: LongPressGestureRecognizer()..onLongPress = onLongPressName, + recognizer: TapGestureRecognizer()..onTap = onTapName, ), ); @@ -351,11 +354,11 @@ class IRCMessage { style: textStyle?.copyWith(fontSize: emoteSize - 5), ), ...emoteStack.reversed.map( - (emote) => CachedNetworkImage( + (emote) => FrostyCachedNetworkImage( imageUrl: emote.url, - fadeInDuration: const Duration(), height: emote.height != null ? emote.height! * emoteScale : emoteSize, width: emote.width != null ? emote.width! * emoteScale : emoteSize, + useFade: false, ), ) ]; @@ -366,43 +369,44 @@ class IRCMessage { WidgetSpan( alignment: PlaceholderAlignment.middle, child: Tooltip( + triggerMode: TooltipTriggerMode.tap, + showDuration: const Duration(seconds: 3), richMessage: WidgetSpan( - child: Padding( - padding: const EdgeInsets.all(5.0), - child: Column( - children: [ - Stack( - alignment: AlignmentDirectional.center, - children: [ - if (nextWordIsEmoji) - Text( - words[index], - style: textStyle?.copyWith(fontSize: 75), - ), - ...emoteStack.reversed.map( - (emote) => CachedNetworkImage( - imageUrl: emote.url, - fadeInDuration: const Duration(), - height: 80, - ), - ) - ], - ), - const SizedBox(height: 5.0), - Column( - children: [ + child: Column( + children: [ + Stack( + alignment: AlignmentDirectional.center, + children: [ + if (nextWordIsEmoji) Text( - nextWordIsEmoji ? '${words[index]} - Emoji' : '${emoteStack.last.name} - ${emoteType[emoteStack.last.type.index]}', - style: const TextStyle(color: Colors.black), + words[index], + style: textStyle?.copyWith(fontSize: 75), ), - Text( - nextWordIsEmoji ? message.join(', ') : message.skip(1).join(', '), - style: const TextStyle(color: Colors.black), + ...emoteStack.reversed.map( + (emote) => FrostyCachedNetworkImage( + imageUrl: emote.url, + height: 80, + useFade: false, ), - ], - ), - ], - ), + ) + ], + ), + const SizedBox(height: 5.0), + Column( + children: [ + Text( + nextWordIsEmoji + ? '${words[index]} - Emoji' + : '${emoteStack.last.name} - ${emoteType[emoteStack.last.type.index]}', + style: const TextStyle(color: Colors.white), + ), + Text( + nextWordIsEmoji ? message.join(', ') : message.skip(1).join(', '), + style: const TextStyle(color: Colors.white), + ), + ], + ), + ], ), ), preferBelow: false, @@ -470,11 +474,11 @@ class IRCMessage { if (backgroundColor != null) { return ColoredBox( color: backgroundColor, - child: CachedNetworkImage( + child: FrostyCachedNetworkImage( imageUrl: badge.url, - fadeInDuration: const Duration(), height: size, width: size, + useFade: false, ), ); } else if (isSvg == true) { @@ -484,11 +488,11 @@ class IRCMessage { width: size, ); } else { - return CachedNetworkImage( + return FrostyCachedNetworkImage( imageUrl: badge.url, - fadeInDuration: const Duration(), height: size, width: size, + useFade: false, ); } } @@ -502,24 +506,23 @@ class IRCMessage { return WidgetSpan( alignment: PlaceholderAlignment.middle, child: Tooltip( + triggerMode: TooltipTriggerMode.tap, + showDuration: const Duration(seconds: 3), richMessage: WidgetSpan( - child: Padding( - padding: const EdgeInsets.all(5.0), - child: Column( - children: [ - _createBadgeWidget( - badge: badge, - size: 80, - backgroundColor: backgroundColor, - isSvg: isSvg, - ), - const SizedBox(height: 5.0), - Text( - badge.name, - style: const TextStyle(color: Colors.black), - ), - ], - ), + child: Column( + children: [ + _createBadgeWidget( + badge: badge, + size: 80, + backgroundColor: backgroundColor, + isSvg: isSvg, + ), + const SizedBox(height: 5.0), + Text( + badge.name, + style: const TextStyle(color: Colors.white), + ), + ], ), ), preferBelow: false, @@ -541,36 +544,35 @@ class IRCMessage { return WidgetSpan( alignment: PlaceholderAlignment.middle, child: Tooltip( + triggerMode: TooltipTriggerMode.tap, + showDuration: const Duration(seconds: 3), richMessage: WidgetSpan( - child: Padding( - padding: const EdgeInsets.all(5.0), - child: Column( - children: [ - CachedNetworkImage( - imageUrl: emote.url, - fadeInDuration: const Duration(), - height: 80, - ), - const SizedBox(height: 5.0), - Text( - emote.name, - style: const TextStyle(color: Colors.black), - ), - Text( - emoteType[emote.type.index], - style: const TextStyle(color: Colors.black), - ), - ], - ), + child: Column( + children: [ + FrostyCachedNetworkImage( + imageUrl: emote.url, + height: 80, + useFade: false, + ), + const SizedBox(height: 5.0), + Text( + emote.name, + style: const TextStyle(color: Colors.white), + ), + Text( + emoteType[emote.type.index], + style: const TextStyle(color: Colors.white), + ), + ], ), ), preferBelow: false, - child: CachedNetworkImage( + child: FrostyCachedNetworkImage( imageUrl: emote.url, - placeholder: (context, url) => const SizedBox(), - fadeInDuration: const Duration(), height: height, width: width, + useFade: false, + placeholder: (context, url) => const SizedBox(), ), ), ); @@ -584,7 +586,8 @@ class IRCMessage { text: text, style: style?.copyWith(color: Colors.blue), recognizer: TapGestureRecognizer() - ..onTap = () => launchUrl(Uri.parse(text), mode: launchExternal ? LaunchMode.externalApplication : LaunchMode.inAppWebView), + ..onTap = () => launchUrl(Uri.parse(text), + mode: launchExternal ? LaunchMode.externalApplication : LaunchMode.inAppWebView), ); } else { return TextSpan(text: text, style: style); @@ -633,7 +636,8 @@ class IRCMessage { // If the username exists, set it. // tmi.twitch.tv means the message was sent by Twitch rather than a user, so will be irrelevant. - final String? user = splitMessage[0] == 'tmi.twitch.tv' ? null : splitMessage[0].substring(0, splitMessage[0].indexOf('!')); + final String? user = + splitMessage[0] == 'tmi.twitch.tv' ? null : splitMessage[0].substring(0, splitMessage[0].indexOf('!')); // If there is an associated message, set it. // @@ -697,7 +701,11 @@ class IRCMessage { if (userLogin != null) mention = message.toLowerCase().contains(userLogin); // Escape the message - message = message.split(' ').map((word) => word.replaceAll('\u{E0000}', '').trim()).where((element) => element != '').join(' '); + message = message + .split(' ') + .map((word) => word.replaceAll('\u{E0000}', '').trim()) + .where((element) => element != '') + .join(' '); final emojiBuffer = StringBuffer(); final wordBuffer = StringBuffer(); diff --git a/lib/screens/channel/channel.dart b/lib/screens/channel/channel.dart index 5b9b7915..1d383893 100644 --- a/lib/screens/channel/channel.dart +++ b/lib/screens/channel/channel.dart @@ -14,12 +14,13 @@ import 'package:frosty/screens/channel/chat/details/chat_details_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/channel/video/video.dart'; +import 'package:frosty/screens/channel/video/video_bar.dart'; import 'package:frosty/screens/channel/video/video_overlay.dart'; import 'package:frosty/screens/channel/video/video_store.dart'; -import 'package:frosty/screens/settings/settings.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; -import 'package:frosty/widgets/modal.dart'; +import 'package:frosty/widgets/app_bar.dart'; +import 'package:frosty/widgets/notification.dart'; import 'package:provider/provider.dart'; import 'package:simple_pip_mode/actions/pip_actions_layout.dart'; import 'package:simple_pip_mode/pip_widget.dart'; @@ -72,32 +73,16 @@ class _VideoChatState extends State { @override Widget build(BuildContext context) { + final orientation = MediaQuery.of(context).orientation; + final settingsStore = _chatStore.settings; - void showSettings() => showModalBottomSheet( - backgroundColor: Colors.transparent, - isScrollControlled: true, - context: context, - builder: (context) => FrostyModal( - child: SizedBox( - height: MediaQuery.of(context).size.height * 0.8, - child: Settings(settingsStore: settingsStore), - ), - ), - ); - - final appBar = AppBar( + final appBar = FrostyAppBar( title: Text( - regexEnglish.hasMatch(_chatStore.displayName) ? _chatStore.displayName : '${_chatStore.displayName} (${_chatStore.channelName})', - style: const TextStyle(fontSize: 20), + regexEnglish.hasMatch(_chatStore.displayName) + ? _chatStore.displayName + : '${_chatStore.displayName} (${_chatStore.channelName})', ), - actions: [ - IconButton( - tooltip: 'Settings', - icon: const Icon(Icons.settings), - onPressed: showSettings, - ), - ], ); final player = GestureDetector( @@ -108,14 +93,10 @@ class _VideoChatState extends State { ), ); - final videoOverlay = VideoOverlay( - videoStore: _videoStore, - onSettingsPressed: showSettings, - ); - final overlay = GestureDetector( onLongPress: _videoStore.handleToggleOverlay, - onDoubleTap: MediaQuery.of(context).orientation == Orientation.landscape ? () => settingsStore.fullScreen = !settingsStore.fullScreen : null, + onDoubleTap: + orientation == Orientation.landscape ? () => settingsStore.fullScreen = !settingsStore.fullScreen : null, onTap: () { if (_chatStore.assetsStore.showEmoteMenu) { _chatStore.assetsStore.showEmoteMenu = false; @@ -129,24 +110,20 @@ class _VideoChatState extends State { }, child: Observer( builder: (_) { + final videoOverlay = VideoOverlay( + videoStore: _videoStore, + chatStore: _chatStore, + ); + if (_videoStore.paused || _videoStore.streamInfo == null) return videoOverlay; return AnimatedOpacity( opacity: _videoStore.overlayVisible ? 1.0 : 0.0, + curve: Curves.ease, duration: const Duration(milliseconds: 200), child: Container( decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - stops: const [0.0, 0.25, 0.75, 1.0], - colors: [ - Colors.black.withOpacity(settingsStore.overlayOpacity), - Colors.transparent, - Colors.transparent, - Colors.black.withOpacity(settingsStore.overlayOpacity), - ], - ), + color: Colors.black.withOpacity(settingsStore.overlayOpacity), ), child: IgnorePointer( ignoring: !_videoStore.overlayVisible, @@ -171,87 +148,147 @@ class _VideoChatState extends State { }, ); - final chat = Chat( - key: _chatKey, - chatStore: _chatStore, + final chat = Observer( + builder: (context) { + final videoBarVisible = _videoStore.streamInfo != null && + _chatStore.settings.showVideo && + (_videoStore.paused || _videoStore.overlayVisible); + + return Stack( + children: [ + Chat( + key: _chatKey, + chatStore: _chatStore, + ), + if (orientation == Orientation.portrait) + AnimatedOpacity( + opacity: videoBarVisible ? 1 : 0, + curve: Curves.ease, + duration: const Duration(milliseconds: 200), + child: IgnorePointer( + ignoring: !videoBarVisible, + child: ColoredBox( + color: Theme.of(context).canvasColor, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_videoStore.streamInfo != null) + VideoBar( + streamInfo: _videoStore.streamInfo!, + tappableCategory: false, + ), + const Divider(height: 1, thickness: 1), + ], + ), + ), + ), + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: _chatStore.notification != null + ? Align( + alignment: + _chatStore.settings.chatNotificationsOnBottom ? Alignment.bottomCenter : Alignment.topCenter, + child: FrostyNotification( + message: _chatStore.notification!, + showPasteButton: _chatStore.notification!.contains('copied'), + onButtonPressed: () async { + // Paste clipboard text into the text controller. + final data = await Clipboard.getData(Clipboard.kTextPlain); + + if (data != null) _chatStore.textController.text = data.text!; + + _chatStore.updateNotification(''); + }, + ), + ) + : null, + ), + ], + ); + }, ); final videoChat = Scaffold( - body: OrientationBuilder( - builder: (context, orientation) { - return Observer( - builder: (_) { - if (orientation == Orientation.landscape && !settingsStore.landscapeForceVerticalChat) { - SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + body: Observer( + builder: (context) { + if (orientation == Orientation.landscape && !settingsStore.landscapeForceVerticalChat) { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); - final landscapeChat = AnimatedContainer( - duration: const Duration(milliseconds: 200), - width: _chatStore.expandChat ? MediaQuery.of(context).size.width / 2 : MediaQuery.of(context).size.width * _chatStore.settings.chatWidth, - curve: Curves.ease, - color: _chatStore.settings.fullScreen - ? Colors.black.withOpacity(_chatStore.settings.fullScreenChatOverlayOpacity) - : Theme.of(context).scaffoldBackgroundColor, - child: chat, - ); + final landscapeChat = AnimatedContainer( + curve: Curves.ease, + duration: const Duration(milliseconds: 200), + width: _chatStore.expandChat + ? MediaQuery.of(context).size.width / 2 + : MediaQuery.of(context).size.width * _chatStore.settings.chatWidth, + color: _chatStore.settings.fullScreen + ? Colors.black.withOpacity(_chatStore.settings.fullScreenChatOverlayOpacity) + : Theme.of(context).scaffoldBackgroundColor, + child: chat, + ); - final overlayChat = Visibility( - visible: settingsStore.fullScreenChatOverlay, - maintainState: true, - child: Theme( - data: darkTheme, - child: DefaultTextStyle( - style: DefaultTextStyle.of(context).style.copyWith(color: Colors.white), - child: landscapeChat, - ), - ), - ); - - return ColoredBox( - color: settingsStore.showVideo ? Colors.black : Theme.of(context).scaffoldBackgroundColor, - child: SafeArea( - bottom: false, - left: - (settingsStore.landscapeCutout == LandscapeCutoutType.both || settingsStore.landscapeCutout == LandscapeCutoutType.left) ? false : true, - right: (settingsStore.landscapeCutout == LandscapeCutoutType.both || settingsStore.landscapeCutout == LandscapeCutoutType.right) - ? false - : true, - child: settingsStore.showVideo - ? settingsStore.fullScreen - ? Stack( - children: [ - player, - if (settingsStore.showOverlay) - Row( - children: settingsStore.landscapeChatLeftSide - ? [overlayChat, Expanded(child: overlay)] - : [Expanded(child: overlay), overlayChat], - ) - ], - ) - : Row( - children: - settingsStore.landscapeChatLeftSide ? [landscapeChat, Expanded(child: video)] : [Expanded(child: video), landscapeChat], - ) - : Column( - children: [appBar, Expanded(child: chat)], - ), - ), - ); - } - - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.manual, - overlays: SystemUiOverlay.values, - ); - return SafeArea( - child: Column( - children: [ - if (!settingsStore.showVideo) appBar else AspectRatio(aspectRatio: 16 / 9, child: video), - Expanded(child: chat), - ], + final overlayChat = Visibility( + visible: settingsStore.fullScreenChatOverlay, + maintainState: true, + child: Theme( + data: darkTheme, + child: DefaultTextStyle( + style: DefaultTextStyle.of(context).style.copyWith(color: Colors.white), + child: landscapeChat, ), - ); - }, + ), + ); + + return ColoredBox( + color: settingsStore.showVideo ? Colors.black : Theme.of(context).scaffoldBackgroundColor, + child: SafeArea( + bottom: false, + left: (settingsStore.landscapeCutout == LandscapeCutoutType.both || + settingsStore.landscapeCutout == LandscapeCutoutType.left) + ? false + : true, + right: (settingsStore.landscapeCutout == LandscapeCutoutType.both || + settingsStore.landscapeCutout == LandscapeCutoutType.right) + ? false + : true, + child: settingsStore.showVideo + ? settingsStore.fullScreen + ? Stack( + children: [ + player, + if (settingsStore.showOverlay) + Row( + children: settingsStore.landscapeChatLeftSide + ? [overlayChat, Expanded(child: overlay)] + : [Expanded(child: overlay), overlayChat], + ) + ], + ) + : Row( + children: settingsStore.landscapeChatLeftSide + ? [landscapeChat, Expanded(child: video)] + : [Expanded(child: video), landscapeChat], + ) + : Column( + children: [appBar, Expanded(child: chat)], + ), + ), + ); + } + + SystemChrome.setEnabledSystemUIMode( + SystemUiMode.manual, + overlays: SystemUiOverlay.values, + ); + return SafeArea( + child: Column( + children: [ + if (!settingsStore.showVideo) appBar else AspectRatio(aspectRatio: 16 / 9, child: video), + Expanded(child: chat), + ], + ), ); }, ), diff --git a/lib/screens/channel/chat/chat.dart b/lib/screens/channel/chat/chat.dart index efc4275b..f302be8a 100644 --- a/lib/screens/channel/chat/chat.dart +++ b/lib/screens/channel/chat/chat.dart @@ -1,12 +1,14 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:frosty/screens/channel/chat/emote_menu/emote_menu.dart'; +import 'package:frosty/screens/channel/chat/emote_menu/emote_menu_panel.dart'; +import 'package:frosty/screens/channel/chat/emote_menu/recent_emotes_panel.dart'; 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/widgets/button.dart'; -import 'package:frosty/widgets/notification.dart'; +import 'package:frosty/widgets/page_view.dart'; class Chat extends StatelessWidget { final ChatStore chatStore; @@ -52,39 +54,19 @@ class Chat extends StatelessWidget { ), ), ), - AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - child: chatStore.notification != null - ? Align( - alignment: chatStore.settings.chatNotificationsOnBottom ? Alignment.bottomCenter : Alignment.topCenter, - child: FrostyNotification( - message: chatStore.notification!, - showPasteButton: chatStore.notification!.contains('copied'), - onButtonPressed: () async { - // Paste clipboard text into the text controller. - final data = await Clipboard.getData(Clipboard.kTextPlain); - - if (data != null) chatStore.textController.text = data.text!; - - chatStore.updateNotification(''); - }, - ), - ) - : null, - ), Padding( padding: const EdgeInsets.all(5.0), child: Observer( builder: (_) => AnimatedSwitcher( duration: const Duration(milliseconds: 200), - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, child: chatStore.autoScroll ? null : Button( padding: const EdgeInsets.symmetric(horizontal: 15.0, vertical: 10.0), onPressed: chatStore.resumeScroll, - icon: const Icon(Icons.arrow_downward), + icon: const Icon(Icons.keyboard_double_arrow_down_rounded), child: const Text('Resume scroll'), ), ), @@ -95,15 +77,60 @@ class Chat extends StatelessWidget { ), ), if (chatStore.settings.showBottomBar) ChatBottomBar(chatStore: chatStore), - AnimatedContainer( - curve: Curves.ease, - duration: const Duration(milliseconds: 200), - height: chatStore.assetsStore.showEmoteMenu ? MediaQuery.of(context).size.height / 3 : 0, - child: AnimatedOpacity( + WillPopScope( + onWillPop: Platform.isAndroid + ? () async { + // If pressing the back button on Android while the emote menu is open, close it instead of going back to the streams list. + if (chatStore.assetsStore.showEmoteMenu) { + chatStore.assetsStore.showEmoteMenu = false; + return false; + } else { + return true; + } + } + : null, + child: AnimatedContainer( curve: Curves.ease, - opacity: chatStore.assetsStore.showEmoteMenu ? 1 : 0, duration: const Duration(milliseconds: 200), - child: EmoteMenu(chatStore: chatStore), + height: chatStore.assetsStore.showEmoteMenu + ? MediaQuery.of(context).size.height / + (MediaQuery.of(context).orientation == Orientation.portrait ? 3 : 2) + : 0, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 100), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: chatStore.assetsStore.showEmoteMenu + ? FrostyPageView( + headers: const [ + 'Recent', + 'Twitch', + '7TV', + 'BTTV', + 'FFZ', + ], + children: [ + RecentEmotesPanel( + chatStore: chatStore, + ), + EmoteMenuPanel( + chatStore: chatStore, + twitchEmotes: chatStore.assetsStore.userEmoteSectionToEmotes, + ), + ...[ + chatStore.assetsStore.sevenTVEmotes, + chatStore.assetsStore.bttvEmotes, + chatStore.assetsStore.ffzEmotes + ].map( + (emotes) => EmoteMenuPanel( + chatStore: chatStore, + emotes: emotes, + ), + ), + ], + ) + : null, + ), ), ), ], diff --git a/lib/screens/channel/chat/details/chat_details.dart b/lib/screens/channel/chat/details/chat_details.dart index f0c469e3..42888b63 100644 --- a/lib/screens/channel/chat/details/chat_details.dart +++ b/lib/screens/channel/chat/details/chat_details.dart @@ -1,10 +1,17 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:frosty/main.dart'; import 'package:frosty/screens/channel/chat/details/chat_details_store.dart'; import 'package:frosty/screens/channel/chat/details/chat_modes.dart'; import 'package:frosty/screens/channel/chat/details/chat_users_list.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; -import 'package:frosty/widgets/modal.dart'; -import 'package:frosty/widgets/section_header.dart'; +import 'package:frosty/screens/settings/settings.dart'; +import 'package:frosty/widgets/bottom_sheet.dart'; +import 'package:frosty/widgets/button.dart'; +import 'package:frosty/widgets/dialog.dart'; +import 'package:frosty/widgets/list_tile.dart'; class ChatDetails extends StatefulWidget { final ChatDetailsStore chatDetailsStore; @@ -23,6 +30,109 @@ class ChatDetails extends StatefulWidget { } class _ChatDetailsState extends State { + Future _showSleepTimerDialog(BuildContext context) { + return showDialog( + context: context, + builder: (context) => FrostyDialog( + title: 'Sleep Timer', + content: Observer( + builder: (context) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + Opacity( + opacity: widget.chatStore.sleepTimer != null && widget.chatStore.sleepTimer!.isActive ? 1.0 : 0.5, + child: Row( + children: [ + const Icon(Icons.timer_rounded), + Text( + ' ${widget.chatStore.timeRemaining.toString().split('.')[0]}', + style: const TextStyle(fontWeight: FontWeight.w600, fontFeatures: [FontFeature.tabularFigures()]), + ), + const Spacer(), + IconButton( + tooltip: 'Cancel sleep timer', + onPressed: widget.chatStore.cancelSleepTimer, + icon: const Icon(Icons.cancel_rounded), + ), + ], + ), + ), + Row( + children: [ + DropdownButton( + value: widget.chatStore.sleepHours, + items: List.generate(24, (index) => index) + .map((e) => DropdownMenuItem(value: e, child: Text(e.toString()))) + .toList(), + onChanged: (int? hours) => widget.chatStore.sleepHours = hours!, + menuMaxHeight: 200, + ), + const SizedBox(width: 10.0), + const Text('Hours'), + ], + ), + Row( + children: [ + DropdownButton( + value: widget.chatStore.sleepMinutes, + items: List.generate(60, (index) => index) + .map((e) => DropdownMenuItem(value: e, child: Text(e.toString()))) + .toList(), + onChanged: (int? minutes) => widget.chatStore.sleepMinutes = minutes!, + menuMaxHeight: 200, + ), + const SizedBox(width: 10.0), + const Text('Minutes'), + ], + ), + ], + ), + ), + actions: [ + Observer( + builder: (context) => Button( + onPressed: widget.chatStore.sleepHours == 0 && widget.chatStore.sleepMinutes == 0 + ? null + : () => widget.chatStore.updateSleepTimer( + onTimerFinished: () => navigatorKey.currentState?.popUntil((route) => route.isFirst), + ), + child: const Text('Set timer'), + ), + ), + Button( + onPressed: Navigator.of(context).pop, + color: Colors.grey, + child: const Text('Close'), + ), + ], + ), + ); + } + + Future _showClearDialog() { + return showDialog( + context: context, + builder: (context) => FrostyDialog( + title: 'Clear Recent Emotes', + message: 'Are you sure you want to clear your recent emotes?', + actions: [ + Button( + onPressed: () { + setState(widget.chatStore.assetsStore.recentEmotes.clear); + Navigator.pop(context); + }, + child: const Text('Yes'), + ), + Button( + onPressed: Navigator.of(context).pop, + color: Colors.grey, + child: const Text('Cancel'), + ), + ], + ), + ); + } + @override void initState() { super.initState(); @@ -31,57 +141,81 @@ class _ChatDetailsState extends State { @override Widget build(BuildContext context) { - return FrostyModal( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SectionHeader('Chat modes'), - ListTile( - title: ChatModes(roomState: widget.chatDetailsStore.roomState), - ), - const SectionHeader('Other'), - ListTile( - leading: const Icon(Icons.people), - title: const Text('Chatters'), - onTap: () => showModalBottomSheet( - backgroundColor: Colors.transparent, - isScrollControlled: true, - context: context, - builder: (context) => FrostyModal( - child: SizedBox( - height: MediaQuery.of(context).size.height * 0.8, - child: GestureDetector( - onTap: FocusScope.of(context).unfocus, - child: ChattersList( - chatDetailsStore: widget.chatDetailsStore, - chatStore: widget.chatStore, - userLogin: widget.userLogin, - ), - ), + final children = [ + ListTile( + title: ChatModes(roomState: widget.chatDetailsStore.roomState), + ), + FrostyListTile( + leading: const Icon(Icons.people_outline), + title: 'Chatters', + onTap: () => showModalBottomSheet( + backgroundColor: Colors.transparent, + isScrollControlled: true, + context: context, + builder: (context) => FrostyBottomSheet( + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.8, + child: GestureDetector( + onTap: FocusScope.of(context).unfocus, + child: ChattersList( + chatDetailsStore: widget.chatDetailsStore, + chatStore: widget.chatStore, + userLogin: widget.userLogin, ), ), ), ), - ListTile( - leading: const Icon(Icons.refresh), - title: const Text('Reconnect to chat'), - onTap: () { - widget.chatStore.updateNotification('Reconnecting to chat...'); - - widget.chatStore.connectToChat(); - }, - ), - ListTile( - leading: const Icon(Icons.refresh), - title: const Text('Refresh badges and emotes'), - onTap: () async { - await widget.chatStore.getAssets(); - - widget.chatStore.updateNotification('Badges and emotes refreshed'); - }, - ), - ], + ), ), + FrostyListTile( + leading: const Icon(Icons.timer_outlined), + title: 'Sleep timer', + onTap: () => _showSleepTimerDialog(context), + ), + FrostyListTile( + leading: const Icon(Icons.delete_outline_rounded), + title: 'Clear recent emotes', + onTap: _showClearDialog, + ), + FrostyListTile( + leading: const Icon(Icons.refresh_rounded), + title: 'Reconnect to chat', + onTap: () { + widget.chatStore.updateNotification('Reconnecting to chat...'); + + widget.chatStore.connectToChat(); + }, + ), + FrostyListTile( + leading: const Icon(Icons.refresh_rounded), + title: 'Refresh badges and emotes', + onTap: () async { + await widget.chatStore.getAssets(); + + widget.chatStore.updateNotification('Badges and emotes refreshed'); + }, + ), + FrostyListTile( + leading: const Icon(Icons.settings_outlined), + title: 'Settings', + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => Settings(settingsStore: widget.chatStore.settings), + ), + ), + ), + ]; + + return FrostyBottomSheet( + child: MediaQuery.of(context).orientation == Orientation.landscape + ? SizedBox( + height: MediaQuery.of(context).size.height * 0.8, + child: ListView( + children: children, + ), + ) + : Column(crossAxisAlignment: CrossAxisAlignment.start, children: children), ); } } diff --git a/lib/screens/channel/chat/details/chat_modes.dart b/lib/screens/channel/chat/details/chat_modes.dart index 7eba3c87..150fd354 100644 --- a/lib/screens/channel/chat/details/chat_modes.dart +++ b/lib/screens/channel/chat/details/chat_modes.dart @@ -15,45 +15,59 @@ class ChatModes extends StatelessWidget { children: [ Tooltip( preferBelow: false, + triggerMode: TooltipTriggerMode.tap, + showDuration: const Duration(seconds: 3), message: 'Emote-only mode ${roomState.emoteOnly != '0' ? 'on' : 'off'}', child: Icon( - Icons.emoji_emotions_outlined, + roomState.emoteOnly != '0' ? Icons.emoji_emotions_rounded : Icons.emoji_emotions_outlined, color: roomState.emoteOnly != '0' ? Colors.yellow : Colors.grey, ), ), Tooltip( preferBelow: false, + triggerMode: TooltipTriggerMode.tap, + showDuration: const Duration(seconds: 3), message: roomState.followersOnly == '-1' ? 'Followers-only mode off' : roomState.followersOnly == '0' ? 'Followers-only mode on' : 'Followers-only mode on (${roomState.followersOnly} ${pluralize('minute', roomState.followersOnly)})', child: Icon( - Icons.favorite, + roomState.followersOnly != '-1' ? Icons.favorite_rounded : Icons.favorite_outline_rounded, color: roomState.followersOnly != '-1' ? Colors.red : Colors.grey, ), ), Tooltip( preferBelow: false, - message: 'R9K mode ${roomState.r9k != '0' ? 'on' : 'off'}', + triggerMode: TooltipTriggerMode.tap, + showDuration: const Duration(seconds: 3), + message: 'R9K (unique-chat) mode ${roomState.r9k != '0' ? 'on' : 'off'}', child: Text( 'R9K', - style: TextStyle(color: roomState.r9k != '0' ? Colors.purple : Colors.grey), + style: TextStyle( + color: roomState.r9k != '0' ? Colors.purple : Colors.grey, + fontWeight: FontWeight.w500, + ), ), ), Tooltip( preferBelow: false, - message: 'Slow mode ${roomState.slowMode != '0' ? 'on (${roomState.slowMode} ${pluralize('second', roomState.slowMode)})' : 'off'}', + triggerMode: TooltipTriggerMode.tap, + showDuration: const Duration(seconds: 3), + message: + 'Slow mode ${roomState.slowMode != '0' ? 'on (${roomState.slowMode} ${pluralize('second', roomState.slowMode)})' : 'off'}', child: Icon( - Icons.history_toggle_off, + roomState.slowMode != '0' ? Icons.hourglass_top_rounded : Icons.hourglass_empty_rounded, color: roomState.slowMode != '0' ? Colors.blue : Colors.grey, ), ), Tooltip( preferBelow: false, + triggerMode: TooltipTriggerMode.tap, + showDuration: const Duration(seconds: 3), message: 'Subs-only mode ${roomState.subMode != '0' ? 'on' : 'off'}', child: Icon( - Icons.attach_money, + roomState.subMode != '0' ? Icons.monetization_on_rounded : Icons.monetization_on_outlined, color: roomState.subMode != '0' ? Colors.green : Colors.grey, ), ), diff --git a/lib/screens/channel/chat/details/chat_users_list.dart b/lib/screens/channel/chat/details/chat_users_list.dart index 47a2d792..a8a762c8 100644 --- a/lib/screens/channel/chat/details/chat_users_list.dart +++ b/lib/screens/channel/chat/details/chat_users_list.dart @@ -15,7 +15,7 @@ import 'package:frosty/widgets/section_header.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; -class ChattersList extends StatelessWidget { +class ChattersList extends StatefulWidget { final ChatDetailsStore chatDetailsStore; final ChatStore chatStore; final String userLogin; @@ -27,13 +27,18 @@ class ChattersList extends StatelessWidget { required this.userLogin, }) : super(key: key); + @override + State createState() => _ChattersListState(); +} + +class _ChattersListState extends State { @override Widget build(BuildContext context) { const headers = [ 'Broadcaster', 'Staff', 'Admins', - 'Global Moderators', + 'Global moderators', 'Moderators', 'VIPs', 'Viewers', @@ -41,25 +46,24 @@ class ChattersList extends StatelessWidget { return Observer( builder: (context) { - if (chatDetailsStore.error != null) { + if (widget.chatDetailsStore.error != null) { return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const AlertMessage( message: 'Failed to get chatters', - icon: Icons.error, ), const SizedBox(height: 10.0), Button( padding: const EdgeInsets.symmetric(horizontal: 15.0), - onPressed: chatDetailsStore.updateChatters, - child: const Text('Try Again'), + onPressed: widget.chatDetailsStore.updateChatters, + child: const Text('Try again'), ) ], ); } - if (chatDetailsStore.chatUsers == null) { + if (widget.chatDetailsStore.chatUsers == null) { return const LoadingIndicator(subtitle: 'Getting chatters...'); } @@ -70,21 +74,24 @@ class ChattersList extends StatelessWidget { child: Observer( builder: (context) { return TextField( - controller: chatDetailsStore.textController, - focusNode: chatDetailsStore.textFieldFocusNode, + controller: widget.chatDetailsStore.textController, + focusNode: widget.chatDetailsStore.textFieldFocusNode, autocorrect: false, decoration: InputDecoration( contentPadding: EdgeInsets.zero, - prefixIcon: const Icon(Icons.filter_list), + prefixIcon: const Icon(Icons.filter_list_rounded), hintText: 'Filter chatters', - suffixIcon: chatDetailsStore.textFieldFocusNode.hasFocus || chatDetailsStore.filterText.isNotEmpty + suffixIcon: widget.chatDetailsStore.textFieldFocusNode.hasFocus || + widget.chatDetailsStore.filterText.isNotEmpty ? IconButton( - tooltip: chatDetailsStore.filterText.isEmpty ? 'Cancel' : 'Clear', + tooltip: widget.chatDetailsStore.filterText.isEmpty ? 'Cancel' : 'Clear', onPressed: () { - if (chatDetailsStore.filterText.isEmpty) chatDetailsStore.textFieldFocusNode.unfocus(); - chatDetailsStore.textController.clear(); + if (widget.chatDetailsStore.filterText.isEmpty) { + widget.chatDetailsStore.textFieldFocusNode.unfocus(); + } + widget.chatDetailsStore.textController.clear(); }, - icon: const Icon(Icons.clear), + icon: const Icon(Icons.close_rounded), ) : null, ), @@ -97,7 +104,7 @@ class ChattersList extends StatelessWidget { onRefresh: () async { HapticFeedback.lightImpact(); - chatDetailsStore.updateChatters(); + widget.chatDetailsStore.updateChatters(); }, child: Stack( alignment: AlignmentDirectional.bottomCenter, @@ -106,14 +113,14 @@ class ChattersList extends StatelessWidget { builder: (context) { return CustomScrollView( physics: const AlwaysScrollableScrollPhysics(), - controller: chatDetailsStore.scrollController, + controller: widget.chatDetailsStore.scrollController, slivers: [ - if (chatDetailsStore.filterText.isEmpty) + if (widget.chatDetailsStore.filterText.isEmpty) SliverPadding( padding: const EdgeInsets.only(left: 10.0, right: 10.0, top: 20.0), sliver: SliverToBoxAdapter( child: Text( - '${NumberFormat().format(chatDetailsStore.chatUsers?.chatterCount)} ${chatDetailsStore.chatUsers?.chatterCount == 1 ? 'Chatter' : 'Chatters'}', + '${NumberFormat().format(widget.chatDetailsStore.chatUsers?.chatterCount)} ${widget.chatDetailsStore.chatUsers?.chatterCount == 1 ? 'Chatter' : 'Chatters'}', style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 18.0, @@ -121,28 +128,22 @@ class ChattersList extends StatelessWidget { ), ), ), - if (chatDetailsStore.chatUsers?.chatterCount == 0) + if (widget.chatDetailsStore.chatUsers?.chatterCount == 0) const SliverFillRemaining( hasScrollBody: false, child: Center( - child: AlertMessage( - message: 'No chatters found', - icon: Icons.person_off, - ), + child: AlertMessage(message: 'No chatters found'), ), ) - else if (chatDetailsStore.filteredUsers.expand((element) => element).isEmpty) + else if (widget.chatDetailsStore.filteredUsers.expand((element) => element).isEmpty) const SliverFillRemaining( hasScrollBody: false, child: Center( - child: AlertMessage( - message: 'No matching chatters', - icon: Icons.person_off, - ), + child: AlertMessage(message: 'No matching chatters'), ), ) else - ...chatDetailsStore.filteredUsers.expandIndexed( + ...widget.chatDetailsStore.filteredUsers.expandIndexed( (index, users) => [ if (users.isNotEmpty) ...[ SliverPadding( @@ -164,16 +165,16 @@ class ChattersList extends StatelessWidget { onLongPress: () async { HapticFeedback.lightImpact(); - final userInfo = await context - .read() - .getUser(headers: context.read().headersTwitch, userLogin: users[index]); + final userInfo = await context.read().getUser( + headers: context.read().headersTwitch, + userLogin: users[index]); showModalBottomSheet( backgroundColor: Colors.transparent, isScrollControlled: true, context: context, builder: (context) => ChatUserModal( - chatStore: chatStore, + chatStore: widget.chatStore, username: userInfo.login, userId: userInfo.id, displayName: userInfo.displayName, @@ -194,9 +195,11 @@ class ChattersList extends StatelessWidget { Observer( builder: (context) => AnimatedSwitcher( duration: const Duration(milliseconds: 200), - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, - child: chatDetailsStore.showJumpButton ? ScrollToTopButton(scrollController: chatDetailsStore.scrollController) : null, + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: widget.chatDetailsStore.showJumpButton + ? ScrollToTopButton(scrollController: widget.chatDetailsStore.scrollController) + : null, ), ), ], @@ -208,4 +211,10 @@ class ChattersList extends StatelessWidget { }, ); } + + @override + void dispose() { + widget.chatDetailsStore.showJumpButton = false; + super.dispose(); + } } diff --git a/lib/screens/channel/chat/emote_menu/emote_menu.dart b/lib/screens/channel/chat/emote_menu/emote_menu.dart deleted file mode 100644 index c5ac731b..00000000 --- a/lib/screens/channel/chat/emote_menu/emote_menu.dart +++ /dev/null @@ -1,136 +0,0 @@ -import 'dart:io'; - -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:frosty/screens/channel/chat/emote_menu/emote_menu_panel.dart'; -import 'package:frosty/screens/channel/chat/emote_menu/recent_emotes_panel.dart'; -import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; -import 'package:frosty/widgets/button.dart'; - -class EmoteMenu extends StatefulWidget { - final ChatStore chatStore; - - const EmoteMenu({ - Key? key, - required this.chatStore, - }) : super(key: key); - - @override - State createState() => _EmoteMenuState(); -} - -class _EmoteMenuState extends State { - late final PageController _pageContoller = PageController(initialPage: widget.chatStore.assetsStore.emoteMenuIndex); - - @override - Widget build(BuildContext context) { - const sections = [ - 'RECENT', - 'TWITCH', - '7TV', - 'BTTV', - 'FFZ', - ]; - - final emoteMenu = Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Divider( - height: 5.0, - thickness: 1.0, - ), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - ...sections.mapIndexed( - (index, section) => Padding( - padding: const EdgeInsets.only(right: 5.0), - child: Observer( - builder: (context) => Button( - onPressed: () { - _pageContoller.animateToPage( - index, - duration: const Duration(milliseconds: 200), - curve: Curves.ease, - ); - widget.chatStore.assetsStore.emoteMenuIndex = index; - }, - color: index == widget.chatStore.assetsStore.emoteMenuIndex ? Theme.of(context).colorScheme.secondary : Colors.grey, - child: Text( - section, - style: const TextStyle( - letterSpacing: 0.8, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ), - ), - ], - ), - ), - Expanded( - child: PageView( - onPageChanged: (index) => widget.chatStore.assetsStore.emoteMenuIndex = index, - controller: _pageContoller, - children: [ - RecentEmotesPanel( - chatStore: widget.chatStore, - ), - Observer( - builder: (_) => EmoteMenuPanel( - chatStore: widget.chatStore, - twitchEmotes: widget.chatStore.assetsStore.userEmoteSectionToEmotes, - ), - ), - Observer( - builder: (_) => EmoteMenuPanel( - chatStore: widget.chatStore, - emotes: widget.chatStore.assetsStore.sevenTVEmotes, - ), - ), - Observer( - builder: (_) => EmoteMenuPanel( - chatStore: widget.chatStore, - emotes: widget.chatStore.assetsStore.bttvEmotes, - ), - ), - Observer( - builder: (_) => EmoteMenuPanel( - chatStore: widget.chatStore, - emotes: widget.chatStore.assetsStore.ffzEmotes, - ), - ), - ], - ), - ), - ], - ); - - if (Platform.isAndroid) { - return WillPopScope( - onWillPop: () async { - // If pressing the back button on Android while the emote menu is open, close it instead of going back to the streams list. - if (widget.chatStore.assetsStore.showEmoteMenu) { - widget.chatStore.assetsStore.showEmoteMenu = false; - return false; - } else { - return true; - } - }, - child: emoteMenu, - ); - } - - return emoteMenu; - } - - @override - void dispose() { - _pageContoller.dispose(); - super.dispose(); - } -} diff --git a/lib/screens/channel/chat/emote_menu/emote_menu_panel.dart b/lib/screens/channel/chat/emote_menu/emote_menu_panel.dart index 2d858b2b..50f19864 100644 --- a/lib/screens/channel/chat/emote_menu/emote_menu_panel.dart +++ b/lib/screens/channel/chat/emote_menu/emote_menu_panel.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:frosty/models/emotes.dart'; import 'package:frosty/screens/channel/chat/emote_menu/emote_menu_section.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; -import 'package:frosty/widgets/section_header.dart'; +import 'package:frosty/widgets/page_view.dart'; class EmoteMenuPanel extends StatelessWidget { final ChatStore chatStore; @@ -19,8 +19,12 @@ class EmoteMenuPanel extends StatelessWidget { @override Widget build(BuildContext context) { if (emotes != null) { - final globalEmotes = - emotes!.where((emote) => emote.type == EmoteType.bttvGlobal || emote.type == EmoteType.ffzGlobal || emote.type == EmoteType.sevenTVGlobal).toList(); + final globalEmotes = emotes! + .where((emote) => + emote.type == EmoteType.bttvGlobal || + emote.type == EmoteType.ffzGlobal || + emote.type == EmoteType.sevenTVGlobal) + .toList(); final channelEmotes = emotes! .where((emote) => @@ -30,47 +34,24 @@ class EmoteMenuPanel extends StatelessWidget { emote.type == EmoteType.sevenTVChannel) .toList(); - return CustomScrollView( - slivers: [ - if (globalEmotes.isNotEmpty) ...[ - const SliverToBoxAdapter( - child: SectionHeader( - 'Global Emotes', - padding: EdgeInsets.symmetric(vertical: 5.0, horizontal: 10.0), - ), - ), - EmoteMenuSection( - chatStore: chatStore, - emotes: globalEmotes, - ), - ], - if (channelEmotes.isNotEmpty) ...[ - const SliverToBoxAdapter( - child: SectionHeader('Channel Emotes'), - ), + return FrostyPageView( + headers: ['Global', if (channelEmotes.isNotEmpty) 'Channel'], + children: [ + EmoteMenuSection( + chatStore: chatStore, + emotes: globalEmotes, + ), + if (channelEmotes.isNotEmpty) EmoteMenuSection( chatStore: chatStore, emotes: channelEmotes, ), - ], ], ); } else { - return CustomScrollView( - slivers: [ - for (final entry in twitchEmotes!.entries) ...[ - SliverToBoxAdapter( - child: SectionHeader( - entry.key, - padding: entry.key == 'Global Emotes' ? const EdgeInsets.symmetric(vertical: 5.0, horizontal: 10.0) : null, - ), - ), - EmoteMenuSection( - chatStore: chatStore, - emotes: entry.value, - ), - ] - ], + return FrostyPageView( + headers: twitchEmotes!.keys.map((header) => header.split(' ')[0]).toList(), + children: twitchEmotes!.entries.map((e) => EmoteMenuSection(chatStore: chatStore, emotes: e.value)).toList(), ); } } diff --git a/lib/screens/channel/chat/emote_menu/emote_menu_section.dart b/lib/screens/channel/chat/emote_menu/emote_menu_section.dart index 3938fea8..1fc3bfb1 100644 --- a/lib/screens/channel/chat/emote_menu/emote_menu_section.dart +++ b/lib/screens/channel/chat/emote_menu/emote_menu_section.dart @@ -1,9 +1,9 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:frosty/constants.dart'; import 'package:frosty/models/emotes.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; +import 'package:frosty/widgets/cached_image.dart'; import 'package:provider/provider.dart'; class EmoteMenuSection extends StatelessWidget { @@ -18,7 +18,7 @@ class EmoteMenuSection extends StatelessWidget { @override Widget build(BuildContext context) { - return SliverGrid( + return GridView.builder( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: MediaQuery.of(context).orientation == Orientation.portrait ? 8 @@ -26,26 +26,24 @@ class EmoteMenuSection extends StatelessWidget { ? 6 : 16, ), - delegate: SliverChildBuilderDelegate( - (context, index) => InkWell( - onTap: () => chatStore.addEmote(emotes[index]), - child: Tooltip( - message: emotes[index].name, - preferBelow: false, - child: Padding( - padding: const EdgeInsets.all(10.0), - child: Center( - child: CachedNetworkImage( - imageUrl: emotes[index].url, - height: emotes[index].height?.toDouble() ?? defaultEmoteSize, - width: emotes[index].width?.toDouble(), - ), + itemBuilder: (context, index) => InkWell( + onTap: () => chatStore.addEmote(emotes[index]), + child: Tooltip( + message: emotes[index].name, + preferBelow: false, + child: Padding( + padding: const EdgeInsets.all(5.0), + child: Center( + child: FrostyCachedNetworkImage( + imageUrl: emotes[index].url, + height: emotes[index].height?.toDouble() ?? defaultEmoteSize, + width: emotes[index].width?.toDouble(), ), ), ), ), - childCount: emotes.length, ), + itemCount: emotes.length, ); } } 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 7e2fba41..92311ba9 100644 --- a/lib/screens/channel/chat/emote_menu/recent_emotes_panel.dart +++ b/lib/screens/channel/chat/emote_menu/recent_emotes_panel.dart @@ -1,15 +1,12 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:frosty/constants.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/widgets/alert_message.dart'; -import 'package:frosty/widgets/button.dart'; -import 'package:frosty/widgets/dialog.dart'; -import 'package:frosty/widgets/section_header.dart'; +import 'package:frosty/widgets/cached_image.dart'; import 'package:provider/provider.dart'; -class RecentEmotesPanel extends StatefulWidget { +class RecentEmotesPanel extends StatelessWidget { final ChatStore chatStore; const RecentEmotesPanel({ @@ -17,68 +14,11 @@ class RecentEmotesPanel extends StatefulWidget { required this.chatStore, }) : super(key: key); - @override - State createState() => _RecentEmotesPanelState(); -} - -class _RecentEmotesPanelState extends State { - Future _showClearDialog() { - return showDialog( - context: context, - builder: (context) => FrostyDialog( - title: 'Clear Recent Emotes', - content: const Text('Are you sure you want to clear your recent emotes?'), - actions: [ - Button( - onPressed: () { - setState(widget.chatStore.assetsStore.recentEmotes.clear); - Navigator.pop(context); - }, - child: const Text('Yes'), - ), - Button( - fill: true, - onPressed: Navigator.of(context).pop, - color: Colors.red.shade700, - child: const Text('Cancel'), - ), - ], - ), - ); - } - @override Widget build(BuildContext context) { return CustomScrollView( slivers: [ - SliverToBoxAdapter( - child: Row( - children: [ - const SectionHeader( - 'Recent Emotes', - padding: EdgeInsets.symmetric(vertical: 5.0, horizontal: 10.0), - ), - const Spacer(), - Container( - padding: const EdgeInsets.symmetric(horizontal: 5.0), - height: 25, - child: Button( - padding: EdgeInsets.zero, - onPressed: widget.chatStore.assetsStore.recentEmotes.isEmpty ? null : _showClearDialog, - color: Theme.of(context).colorScheme.secondary, - child: const Text( - 'CLEAR', - style: TextStyle( - letterSpacing: 0.8, - fontWeight: FontWeight.w500, - ), - ), - ), - ), - ], - ), - ), - if (widget.chatStore.assetsStore.recentEmotes.isEmpty) + if (chatStore.assetsStore.recentEmotes.isEmpty) const SliverFillRemaining( hasScrollBody: false, child: AlertMessage(message: 'No recent emotes'), @@ -94,19 +34,23 @@ class _RecentEmotesPanelState extends State { ), delegate: SliverChildBuilderDelegate( (context, index) { - final emote = widget.chatStore.assetsStore.recentEmotes[index]; - final validEmotes = [...widget.chatStore.assetsStore.emoteToObject.values, ...widget.chatStore.assetsStore.userEmoteToObject.values]; - final matchingEmotes = validEmotes.where((existingEmote) => existingEmote.name == emote.name && existingEmote.type == emote.type); + 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 ? () => widget.chatStore.addEmote(emote) : null, + onTap: matchingEmotes.isNotEmpty ? () => chatStore.addEmote(emote) : null, child: Tooltip( message: emote.name, preferBelow: false, child: Padding( padding: const EdgeInsets.all(10.0), child: Center( - child: CachedNetworkImage( + 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, @@ -118,7 +62,7 @@ class _RecentEmotesPanelState extends State { ), ); }, - childCount: widget.chatStore.assetsStore.recentEmotes.length, + childCount: chatStore.assetsStore.recentEmotes.length, ), ), ], diff --git a/lib/screens/channel/chat/stores/chat_assets_store.dart b/lib/screens/channel/chat/stores/chat_assets_store.dart index 780542e2..efc9a281 100644 --- a/lib/screens/channel/chat/stores/chat_assets_store.dart +++ b/lib/screens/channel/chat/stores/chat_assets_store.dart @@ -63,16 +63,14 @@ abstract class ChatAssetsStoreBase with Store { @readonly var _userToBTTVBadges = {}; - /// The current index of the emote menu stack. - @observable - var emoteMenuIndex = 0; - /// Whether or not the emote menu is visible. @observable var showEmoteMenu = false; bool isBTTV(Emote emote) { - return emote.type == EmoteType.bttvChannel || emote.type == EmoteType.bttvGlobal || emote.type == EmoteType.bttvShared; + return emote.type == EmoteType.bttvChannel || + emote.type == EmoteType.bttvGlobal || + emote.type == EmoteType.bttvShared; } bool isFFZ(Emote emote) { @@ -97,8 +95,12 @@ abstract class ChatAssetsStoreBase with Store { // Retrieve the instance that will allow us to retrieve local search history. final prefs = await SharedPreferences.getInstance(); - _recentEmotes = - prefs.getStringList('recent_emotes')?.map((emoteJson) => Emote.fromJson(jsonDecode(emoteJson))).toList().asObservable() ?? ObservableList(); + _recentEmotes = prefs + .getStringList('recent_emotes') + ?.map((emoteJson) => Emote.fromJson(jsonDecode(emoteJson))) + .toList() + .asObservable() ?? + ObservableList(); _disposeReaction = autorun((_) { if (_recentEmotes.length > 48) _recentEmotes.removeLast(); @@ -150,7 +152,9 @@ abstract class ChatAssetsStoreBase with Store { ffzRoomInfo = ffzRoom.item1; return ffzRoom.item2; }).catchError(onError), - ]).then((assets) => assets.expand((list) => list)).then((emotes) => _emoteToObject = {for (final emote in emotes) emote.name: emote}); + ]) + .then((assets) => assets.expand((list) => list)) + .then((emotes) => _emoteToObject = {for (final emote in emotes) emote.name: emote}); @action Future badgesFuture({ @@ -161,10 +165,10 @@ abstract class ChatAssetsStoreBase with Store { Future.wait([ // Get global badges first, then channel badges to avoid badge conflicts. // We want the channel badges to override the global badges. - twitchApi - .getBadgesGlobal() + twitchApi.getBadgesGlobal().then((badges) => twitchBadgesToObject.addAll(badges)).then((_) => twitchApi + .getBadgesChannel(id: channelId) .then((badges) => twitchBadgesToObject.addAll(badges)) - .then((_) => twitchApi.getBadgesChannel(id: channelId).then((badges) => twitchBadgesToObject.addAll(badges)).catchError(onError)), + .catchError(onError)), ffzApi.getBadges().then((badges) => _userToFFZBadges = badges).catchError(onError), sevenTVApi.getBadges().then((badges) => _userTo7TVBadges = badges).catchError(onError), bttvApi.getBadges().then((badges) => _userToBTTVBadges = badges).catchError(onError), @@ -176,7 +180,8 @@ abstract class ChatAssetsStoreBase with Store { required Map headers, required Function onError, }) async { - final userEmotes = await Future.wait(emoteSets.map((setId) => twitchApi.getEmotesSets(setId: setId, headers: headers).catchError(onError))); + final userEmotes = await Future.wait( + emoteSets.map((setId) => twitchApi.getEmotesSets(setId: setId, headers: headers).catchError(onError))); for (final emoteSet in userEmotes) { if (emoteSet.isNotEmpty) { diff --git a/lib/screens/channel/chat/stores/chat_assets_store.g.dart b/lib/screens/channel/chat/stores/chat_assets_store.g.dart index 9c9f7ab8..4961e104 100644 --- a/lib/screens/channel/chat/stores/chat_assets_store.g.dart +++ b/lib/screens/channel/chat/stores/chat_assets_store.g.dart @@ -159,22 +159,6 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store { }); } - late final _$emoteMenuIndexAtom = - Atom(name: 'ChatAssetsStoreBase.emoteMenuIndex', context: context); - - @override - int get emoteMenuIndex { - _$emoteMenuIndexAtom.reportRead(); - return super.emoteMenuIndex; - } - - @override - set emoteMenuIndex(int value) { - _$emoteMenuIndexAtom.reportWrite(value, super.emoteMenuIndex, () { - super.emoteMenuIndex = value; - }); - } - late final _$showEmoteMenuAtom = Atom(name: 'ChatAssetsStoreBase.showEmoteMenu', context: context); @@ -266,7 +250,6 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store { @override String toString() { return ''' -emoteMenuIndex: ${emoteMenuIndex}, showEmoteMenu: ${showEmoteMenu}, bttvEmotes: ${bttvEmotes}, ffzEmotes: ${ffzEmotes}, diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index fa8d693c..754c0406 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -86,6 +86,21 @@ abstract class ChatStoreBase with Store { /// Timer used for dismissing the notification. Timer? _notificationTimer; + /// The current timer for the sleep timer if active. + Timer? sleepTimer; + + /// The amount of hours the sleep timer is set to. + @observable + var sleepHours = 0; + + /// The amount of minutes the sleep timer is set to. + @observable + var sleepMinutes = 0; + + /// The time remaining for the sleep timer. + @observable + var timeRemaining = const Duration(); + /// A notification message to display above the chat. @readonly String? _notification; @@ -158,7 +173,8 @@ abstract class ChatStoreBase with Store { if (settings.chatDelay > 0) { _messageBuffer.add(IRCMessage.createNotice( - message: 'Waiting ${settings.chatDelay.toInt()} ${settings.chatDelay == 1.0 ? 'second' : 'seconds'} due to message delay setting...')); + message: + 'Waiting ${settings.chatDelay.toInt()} ${settings.chatDelay == 1.0 ? 'second' : 'seconds'} due to message delay setting...')); } connectToChat(); @@ -189,8 +205,8 @@ abstract class ChatStoreBase with Store { // Add a listener to the textfield that will show/hide the autocomplete bar if focused. // Will also rebuild the autocomplete bar when typing, refreshing the results as the user types. - textController - .addListener(() => _showEmoteAutocomplete = !_showMentionAutocomplete && textFieldFocusNode.hasFocus && textController.text.split(' ').last.isNotEmpty); + textController.addListener(() => _showEmoteAutocomplete = + !_showMentionAutocomplete && textFieldFocusNode.hasFocus && textController.text.split(' ').last.isNotEmpty); textController.addListener(() { _showSendButton = textController.text.isNotEmpty; @@ -214,7 +230,9 @@ abstract class ChatStoreBase with Store { // Filter messages from any blocked users if not a moderator or not the channel owner. if (!_userState.mod && channelName != auth.user.details?.login && - auth.user.blockedUsers.where((blockedUser) => blockedUser.userLogin == parsedIRCMessage.user).isNotEmpty) continue; + auth.user.blockedUsers.where((blockedUser) => blockedUser.userLogin == parsedIRCMessage.user).isNotEmpty) { + continue; + } switch (parsedIRCMessage.command) { case Command.privateMessage: @@ -280,7 +298,9 @@ abstract class ChatStoreBase with Store { _channel?.sink.add('PONG :tmi.twitch.tv'); return; } else if (message.contains('Welcome, GLHF!')) { - _messageBuffer.add(IRCMessage.createNotice(message: "Connected to $displayName${regexEnglish.hasMatch(displayName) ? '' : ' ($channelName)'}'s chat!")); + _messageBuffer.add(IRCMessage.createNotice( + message: + "Connected to $displayName${regexEnglish.hasMatch(displayName) ? '' : ' ($channelName)'}'s chat!")); getAssets(); @@ -334,7 +354,8 @@ abstract class ChatStoreBase with Store { if (_backoffTime > 0) { // Add notice that chat was disconnected and then wait the backoff time before reconnecting. - final notice = 'Disconnected from chat, waiting $_backoffTime ${_backoffTime == 1 ? 'second' : 'seconds'} before reconnecting...'; + final notice = + 'Disconnected from chat, waiting $_backoffTime ${_backoffTime == 1 ? 'second' : 'seconds'} before reconnecting...'; _messageBuffer.add(IRCMessage.createNotice(message: notice)); } @@ -451,10 +472,52 @@ abstract class ChatStoreBase with Store { _notificationTimer = Timer(const Duration(seconds: 2), () => _notification = null); } + /// Updates the sleep timer with [sleepHours] and [sleepMinutes]. + /// Calls [onTimerFinished] when the sleep timer completes. + @action + void updateSleepTimer({required void Function() onTimerFinished}) { + // If hours and minutes are 0, do nothing. + if (sleepHours == 0 && sleepMinutes == 0) return; + + // If there is an ongoing timer, cancel it since it'll be replaced. + if (sleepTimer != null) cancelSleepTimer(); + + // Update the new time remaining + timeRemaining = Duration(hours: sleepHours, minutes: sleepMinutes); + + // Reset the hours and minutes in the dropdown buttons. + sleepHours = 0; + sleepMinutes = 0; + + // Set a periodic timer that will update the time remaining every second. + sleepTimer = Timer.periodic( + const Duration(seconds: 1), + (timer) { + // If the timer is up, cancel the timer and exit the app. + if (timeRemaining.inSeconds == 0) { + timer.cancel(); + onTimerFinished(); + return; + } + + // Decrement the time remaining. + timeRemaining = Duration(seconds: timeRemaining.inSeconds - 1); + }, + ); + } + + /// Cancels the sleep timer and resets the time remaining. + @action + void cancelSleepTimer() { + sleepTimer?.cancel(); + timeRemaining = const Duration(); + } + /// Closes and disposes all the channels and controllers used by the store. void dispose() { _messageBufferTimer.cancel(); _notificationTimer?.cancel(); + sleepTimer?.cancel(); _channel?.sink.close(1001); _channel = null; diff --git a/lib/screens/channel/chat/stores/chat_store.g.dart b/lib/screens/channel/chat/stores/chat_store.g.dart index 700ff54d..39a130ca 100644 --- a/lib/screens/channel/chat/stores/chat_store.g.dart +++ b/lib/screens/channel/chat/stores/chat_store.g.dart @@ -17,6 +17,54 @@ mixin _$ChatStore on ChatStoreBase, Store { name: 'ChatStoreBase.renderMessages')) .value; + late final _$sleepHoursAtom = + Atom(name: 'ChatStoreBase.sleepHours', context: context); + + @override + int get sleepHours { + _$sleepHoursAtom.reportRead(); + return super.sleepHours; + } + + @override + set sleepHours(int value) { + _$sleepHoursAtom.reportWrite(value, super.sleepHours, () { + super.sleepHours = value; + }); + } + + late final _$sleepMinutesAtom = + Atom(name: 'ChatStoreBase.sleepMinutes', context: context); + + @override + int get sleepMinutes { + _$sleepMinutesAtom.reportRead(); + return super.sleepMinutes; + } + + @override + set sleepMinutes(int value) { + _$sleepMinutesAtom.reportWrite(value, super.sleepMinutes, () { + super.sleepMinutes = value; + }); + } + + late final _$timeRemainingAtom = + Atom(name: 'ChatStoreBase.timeRemaining', context: context); + + @override + Duration get timeRemaining { + _$timeRemainingAtom.reportRead(); + return super.timeRemaining; + } + + @override + set timeRemaining(Duration value) { + _$timeRemainingAtom.reportWrite(value, super.timeRemaining, () { + super.timeRemaining = value; + }); + } + late final _$_notificationAtom = Atom(name: 'ChatStoreBase._notification', context: context); @@ -249,9 +297,34 @@ mixin _$ChatStore on ChatStoreBase, Store { } } + @override + void updateSleepTimer({required void Function() onTimerFinished}) { + final _$actionInfo = _$ChatStoreBaseActionController.startAction( + name: 'ChatStoreBase.updateSleepTimer'); + try { + return super.updateSleepTimer(onTimerFinished: onTimerFinished); + } finally { + _$ChatStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void cancelSleepTimer() { + final _$actionInfo = _$ChatStoreBaseActionController.startAction( + name: 'ChatStoreBase.cancelSleepTimer'); + try { + return super.cancelSleepTimer(); + } finally { + _$ChatStoreBaseActionController.endAction(_$actionInfo); + } + } + @override String toString() { return ''' +sleepHours: ${sleepHours}, +sleepMinutes: ${sleepMinutes}, +timeRemaining: ${timeRemaining}, expandChat: ${expandChat}, renderMessages: ${renderMessages} '''; diff --git a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart index 4ab18431..eb0cff6d 100644 --- a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart +++ b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart @@ -1,10 +1,10 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/constants.dart'; import 'package:frosty/screens/channel/chat/details/chat_details.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/widgets/button.dart'; +import 'package:frosty/widgets/cached_image.dart'; class ChatBottomBar extends StatelessWidget { final ChatStore chatStore; @@ -13,14 +13,17 @@ class ChatBottomBar extends StatelessWidget { @override Widget build(BuildContext context) { - final emoteMenuButton = IconButton( - color: chatStore.assetsStore.showEmoteMenu ? Theme.of(context).colorScheme.secondary : null, - tooltip: 'Emote menu', - icon: const Icon(Icons.emoji_emotions_outlined), - onPressed: () { - FocusScope.of(context).unfocus(); - chatStore.assetsStore.showEmoteMenu = !chatStore.assetsStore.showEmoteMenu; - }, + final emoteMenuButton = Tooltip( + message: 'Emote menu', + preferBelow: false, + child: IconButton( + color: chatStore.assetsStore.showEmoteMenu ? Theme.of(context).colorScheme.secondary : null, + icon: Icon(chatStore.assetsStore.showEmoteMenu ? Icons.emoji_emotions_rounded : Icons.emoji_emotions_outlined), + onPressed: () { + FocusScope.of(context).unfocus(); + chatStore.assetsStore.showEmoteMenu = !chatStore.assetsStore.showEmoteMenu; + }, + ), ); return Observer( @@ -30,10 +33,14 @@ class ChatBottomBar extends StatelessWidget { ...chatStore.assetsStore.bttvEmotes, ...chatStore.assetsStore.ffzEmotes, ...chatStore.assetsStore.sevenTVEmotes - ].where((emote) => emote.name.toLowerCase().contains(chatStore.textController.text.split(' ').last.toLowerCase())).toList(); + ] + .where((emote) => + emote.name.toLowerCase().contains(chatStore.textController.text.split(' ').last.toLowerCase())) + .toList(); final matchingChatters = chatStore.chatDetailsStore.allChatters - .where((chatter) => chatter.contains(chatStore.textController.text.split(' ').last.replaceFirst('@', '').toLowerCase())) + .where((chatter) => + chatter.contains(chatStore.textController.text.split(' ').last.replaceFirst('@', '').toLowerCase())) .toList(); return Column( @@ -57,9 +64,9 @@ class ChatBottomBar extends StatelessWidget { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 5.0), child: Center( - child: CachedNetworkImage( + child: FrostyCachedNetworkImage( imageUrl: matchingEmotes[index].url, - fadeInDuration: const Duration(), + useFade: false, height: matchingEmotes[index].height?.toDouble() ?? defaultEmoteSize, width: matchingEmotes[index].width?.toDouble(), ), @@ -70,7 +77,9 @@ class ChatBottomBar extends StatelessWidget { ), ), ], - if (chatStore.settings.autocomplete && chatStore.showMentionAutocomplete && matchingChatters.isNotEmpty) ...[ + if (chatStore.settings.autocomplete && + chatStore.showMentionAutocomplete && + matchingChatters.isNotEmpty) ...[ const Divider( height: 1.0, thickness: 1.0, @@ -89,7 +98,8 @@ class ChatBottomBar extends StatelessWidget { ..add('@${matchingChatters[index]} '); chatStore.textController.text = split.join(' '); - chatStore.textController.selection = TextSelection.fromPosition(TextPosition(offset: chatStore.textController.text.length)); + chatStore.textController.selection = + TextSelection.fromPosition(TextPosition(offset: chatStore.textController.text.length)); }, child: Text(matchingChatters[index]), ), @@ -105,12 +115,12 @@ class ChatBottomBar extends StatelessWidget { chatStore.settings.showVideo && MediaQuery.of(context).orientation == Orientation.landscape) IconButton( - tooltip: 'Send a message', + tooltip: 'Enter a message', onPressed: () { chatStore.expandChat = true; chatStore.textFieldFocusNode.requestFocus(); }, - icon: const Icon(Icons.chat), + icon: const Icon(Icons.edit), ) else Expanded( @@ -121,7 +131,7 @@ class ChatBottomBar extends StatelessWidget { maxLines: 5, enabled: chatStore.auth.isLoggedIn ? true : false, decoration: InputDecoration( - contentPadding: const EdgeInsets.fromLTRB(15.0, 10.0, 0.0, 10.0), + contentPadding: const EdgeInsets.only(left: 15.0), prefixIcon: chatStore.settings.emoteMenuButtonOnLeft ? emoteMenuButton : null, suffixIcon: chatStore.settings.emoteMenuButtonOnLeft ? null : emoteMenuButton, hintMaxLines: 1, @@ -134,15 +144,18 @@ class ChatBottomBar extends StatelessWidget { ), ), if (chatStore.showSendButton && - (chatStore.settings.chatWidth >= 0.3 || chatStore.expandChat || MediaQuery.of(context).orientation == Orientation.portrait)) + (chatStore.settings.chatWidth >= 0.3 || + chatStore.expandChat || + MediaQuery.of(context).orientation == Orientation.portrait)) IconButton( tooltip: 'Send', - icon: const Icon(Icons.send), - onPressed: chatStore.auth.isLoggedIn ? () => chatStore.sendMessage(chatStore.textController.text) : null, + icon: const Icon(Icons.send_rounded), + onPressed: + chatStore.auth.isLoggedIn ? () => chatStore.sendMessage(chatStore.textController.text) : null, ) else IconButton( - icon: Icon(Icons.adaptive.more), + icon: Icon(Icons.adaptive.more_rounded), tooltip: 'More', onPressed: () => showModalBottomSheet( backgroundColor: Colors.transparent, diff --git a/lib/screens/channel/chat/widgets/chat_message.dart b/lib/screens/channel/chat/widgets/chat_message.dart index 4c1a4ea7..2c544577 100644 --- a/lib/screens/channel/chat/widgets/chat_message.dart +++ b/lib/screens/channel/chat/widgets/chat_message.dart @@ -20,15 +20,13 @@ class ChatMessage extends StatelessWidget { @override Widget build(BuildContext context) { - void onLongPressName() { + void onTapName() { // Ignore if the message is a recent message in the modal bottom sheet. if (isModal) return; // Ignore if long-pressing own username. if (ircMessage.user == null || ircMessage.user == chatStore.auth.user.details?.login) return; - HapticFeedback.lightImpact(); - showModalBottomSheet( backgroundColor: Colors.transparent, isScrollControlled: true, @@ -62,12 +60,14 @@ class ChatMessage extends StatelessWidget { case Command.userState: // If user is being mentioned in the message, highlight it red. if (ircMessage.mention == true) color = Colors.red.withOpacity(0.2); - if (chatStore.settings.highlightFirstTimeChatter && ircMessage.tags['first-msg'] == '1') color = Colors.green.withOpacity(0.2); + if (chatStore.settings.highlightFirstTimeChatter && ircMessage.tags['first-msg'] == '1') { + color = Colors.green.withOpacity(0.2); + } final messageSpan = Text.rich( TextSpan( children: ircMessage.generateSpan( - onLongPressName: onLongPressName, + onTapName: onTapName, style: defaultTextStyle, assetsStore: chatStore.assetsStore, emoteScale: chatStore.settings.emoteScale, @@ -84,14 +84,15 @@ class ChatMessage extends StatelessWidget { final replyUser = ircMessage.tags['reply-parent-display-name']; final replyBody = ircMessage.tags['reply-parent-msg-body']; - if ((replyUser != null && replyBody != null) || (chatStore.settings.highlightFirstTimeChatter && ircMessage.tags['first-msg'] == '1')) { + if ((replyUser != null && replyBody != null) || + (chatStore.settings.highlightFirstTimeChatter && ircMessage.tags['first-msg'] == '1')) { renderMessage = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( - replyUser != null && replyBody != null ? Icons.reply : Icons.new_releases_outlined, + replyUser != null && replyBody != null ? Icons.reply_rounded : Icons.new_releases_outlined, size: defaultBadgeSize * chatStore.settings.badgeScale, color: defaultTextStyle.color?.withOpacity(0.5), textDirection: TextDirection.rtl, @@ -102,6 +103,8 @@ class ChatMessage extends StatelessWidget { ? Tooltip( message: 'Replying to @$replyUser: $replyBody', preferBelow: false, + triggerMode: TooltipTriggerMode.tap, + showDuration: const Duration(seconds: 5), child: Text( 'Replying to @$replyUser: $replyBody', maxLines: 1, @@ -160,7 +163,7 @@ class ChatMessage extends StatelessWidget { Text.rich( TextSpan( children: ircMessage.generateSpan( - onLongPressName: onLongPressName, + onTapName: onTapName, style: defaultTextStyle, assetsStore: chatStore.assetsStore, emoteScale: chatStore.settings.emoteScale, @@ -214,7 +217,7 @@ class ChatMessage extends StatelessWidget { Text.rich( TextSpan( children: ircMessage.generateSpan( - onLongPressName: onLongPressName, + onTapName: onTapName, style: defaultTextStyle, assetsStore: chatStore.assetsStore, emoteScale: chatStore.settings.emoteScale, diff --git a/lib/screens/channel/chat/widgets/chat_user_modal.dart b/lib/screens/channel/chat/widgets/chat_user_modal.dart index 9795cbfc..b510c882 100644 --- a/lib/screens/channel/chat/widgets/chat_user_modal.dart +++ b/lib/screens/channel/chat/widgets/chat_user_modal.dart @@ -1,13 +1,12 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/constants.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_message.dart'; import 'package:frosty/widgets/alert_message.dart'; import 'package:frosty/widgets/block_report_modal.dart'; +import 'package:frosty/widgets/bottom_sheet.dart'; import 'package:frosty/widgets/button.dart'; -import 'package:frosty/widgets/modal.dart'; import 'package:frosty/widgets/profile_picture.dart'; import 'package:frosty/widgets/section_header.dart'; @@ -32,9 +31,10 @@ class ChatUserModal extends StatefulWidget { class _ChatUserModalState extends State { @override Widget build(BuildContext context) { - final name = regexEnglish.hasMatch(widget.displayName) ? widget.displayName : '${widget.displayName} (${widget.username})'; + final name = + regexEnglish.hasMatch(widget.displayName) ? widget.displayName : '${widget.displayName} (${widget.username})'; - return FrostyModal( + return FrostyBottomSheet( child: SizedBox( height: MediaQuery.of(context).size.height * 0.5, child: Column( @@ -49,7 +49,6 @@ class _ChatUserModalState extends State { children: [ Flexible( child: Tooltip( - preferBelow: false, message: name, child: Text( name, @@ -71,29 +70,26 @@ class _ChatUserModalState extends State { child: const Text('Reply'), ) : null, - onLongPress: () { - HapticFeedback.mediumImpact(); - - showModalBottomSheet( - backgroundColor: Colors.transparent, - context: context, - builder: (context) => BlockReportModal( - authStore: widget.chatStore.auth, - name: name, - userLogin: widget.username, - userId: widget.userId, - ), - ); - }, + onTap: () => showModalBottomSheet( + backgroundColor: Colors.transparent, + context: context, + builder: (context) => BlockReportModal( + authStore: widget.chatStore.auth, + name: name, + userLogin: widget.username, + userId: widget.userId, + ), + ), ), const SectionHeader( - 'Recent Messages', + 'Recent messages', padding: EdgeInsets.all(10.0), ), Expanded( child: Observer( builder: (context) { - final userMessages = widget.chatStore.messages.reversed.where((message) => message.user == widget.username).toList(); + final userMessages = + widget.chatStore.messages.reversed.where((message) => message.user == widget.username).toList(); if (userMessages.isEmpty) { return const AlertMessage(message: 'No recent messages'); diff --git a/lib/screens/channel/video/video_bar.dart b/lib/screens/channel/video/video_bar.dart new file mode 100644 index 00000000..0998590b --- /dev/null +++ b/lib/screens/channel/video/video_bar.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:frosty/constants.dart'; +import 'package:frosty/models/stream.dart'; +import 'package:frosty/screens/home/top/categories/category_streams.dart'; +import 'package:frosty/widgets/profile_picture.dart'; + +class VideoBar extends StatelessWidget { + final StreamTwitch streamInfo; + final bool showCategory; + final bool tappableCategory; + final Color? titleTextColor; + final Color? subtitleTextColor; + final FontWeight? subtitleTextWeight; + + const VideoBar({ + Key? key, + required this.streamInfo, + this.showCategory = true, + this.tappableCategory = true, + this.titleTextColor, + this.subtitleTextColor, + this.subtitleTextWeight, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final streamTitle = streamInfo.title.trim(); + final category = streamInfo.gameName.isNotEmpty ? streamInfo.gameName : 'No Category'; + + final streamerName = regexEnglish.hasMatch(streamInfo.userName) + ? streamInfo.userName + : '${streamInfo.userName} (${streamInfo.userLogin})'; + + return Padding( + padding: const EdgeInsets.all(10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ProfilePicture(userLogin: streamInfo.userLogin), + const SizedBox(width: 10.0), + Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Tooltip( + message: 'Streamer: $streamerName', + showDuration: const Duration(seconds: 3), + child: Text( + streamerName, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 16, + color: titleTextColor, + ), + ), + ), + const SizedBox(height: 5.0), + Tooltip( + message: 'Title: ${streamInfo.title.trim()}', + showDuration: const Duration(seconds: 5), + child: Text( + streamTitle, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: subtitleTextColor ?? Theme.of(context).colorScheme.onSurface.withOpacity(0.8), + fontWeight: subtitleTextWeight, + ), + ), + ), + if (showCategory) ...[ + const SizedBox(height: 5.0), + InkWell( + onTap: tappableCategory && streamInfo.gameName.isNotEmpty + ? () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => CategoryStreams( + categoryName: streamInfo.gameName, + categoryId: streamInfo.gameId, + ), + ), + ) + : null, + child: Tooltip( + message: 'Category: ${streamInfo.gameName.isNotEmpty ? streamInfo.gameName : 'None'}', + showDuration: const Duration(seconds: 3), + child: Text( + category, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: subtitleTextColor ?? Theme.of(context).colorScheme.onSurface.withOpacity(0.8), + fontWeight: subtitleTextWeight, + ), + ), + ), + ), + ], + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/channel/video/video_overlay.dart b/lib/screens/channel/video/video_overlay.dart index 95b2345b..55e5a9f2 100644 --- a/lib/screens/channel/video/video_overlay.dart +++ b/lib/screens/channel/video/video_overlay.dart @@ -3,96 +3,25 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:frosty/constants.dart'; -import 'package:frosty/main.dart'; +import 'package:frosty/screens/channel/chat/details/chat_users_list.dart'; +import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; +import 'package:frosty/screens/channel/video/video_bar.dart'; import 'package:frosty/screens/channel/video/video_store.dart'; -import 'package:frosty/widgets/button.dart'; -import 'package:frosty/widgets/dialog.dart'; -import 'package:frosty/widgets/profile_picture.dart'; +import 'package:frosty/widgets/bottom_sheet.dart'; import 'package:frosty/widgets/uptime.dart'; import 'package:intl/intl.dart'; /// Creates a widget containing controls which enable interactions with an underlying [Video] widget. class VideoOverlay extends StatelessWidget { final VideoStore videoStore; - final void Function() onSettingsPressed; + final ChatStore chatStore; const VideoOverlay({ Key? key, required this.videoStore, - required this.onSettingsPressed, + required this.chatStore, }) : super(key: key); - Future _showSleepTimerDialog(BuildContext context) { - return showDialog( - context: context, - builder: (context) => FrostyDialog( - title: 'Sleep Timer', - content: Observer( - builder: (context) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - const Icon(Icons.timer), - Text(' ${videoStore.timeRemaining.toString().split('.')[0]}'), - const Spacer(), - IconButton( - tooltip: 'Cancel sleep timer', - onPressed: videoStore.sleepTimer != null && videoStore.sleepTimer!.isActive ? videoStore.cancelSleepTimer : null, - icon: const Icon(Icons.cancel), - ), - ], - ), - Row( - children: [ - DropdownButton( - value: videoStore.sleepHours, - items: List.generate(24, (index) => index).map((e) => DropdownMenuItem(value: e, child: Text(e.toString()))).toList(), - onChanged: (int? hours) => videoStore.sleepHours = hours!, - menuMaxHeight: 200, - ), - const SizedBox(width: 10.0), - const Text('Hours'), - ], - ), - Row( - children: [ - DropdownButton( - value: videoStore.sleepMinutes, - items: List.generate(60, (index) => index).map((e) => DropdownMenuItem(value: e, child: Text(e.toString()))).toList(), - onChanged: (int? minutes) => videoStore.sleepMinutes = minutes!, - menuMaxHeight: 200, - ), - const SizedBox(width: 10.0), - const Text('Minutes'), - ], - ), - ], - ), - ), - actions: [ - Observer( - builder: (context) => Button( - onPressed: videoStore.sleepHours == 0 && videoStore.sleepMinutes == 0 - ? null - : () => videoStore.updateSleepTimer( - onTimerFinished: () => navigatorKey.currentState?.popUntil((route) => route.isFirst), - ), - child: const Text('Set Timer'), - ), - ), - Button( - onPressed: Navigator.of(context).pop, - fill: true, - color: Colors.red.shade700, - child: const Text('Close'), - ), - ], - ), - ); - } - @override Widget build(BuildContext context) { final orientation = MediaQuery.of(context).orientation; @@ -100,88 +29,77 @@ class VideoOverlay extends StatelessWidget { final backButton = IconButton( tooltip: 'Back', icon: Icon( - Icons.adaptive.arrow_back, + Icons.adaptive.arrow_back_rounded, color: Colors.white, ), onPressed: Navigator.of(context).pop, ); - final settingsButton = IconButton( - tooltip: 'Settings', - icon: const Icon( - Icons.settings, - color: Colors.white, - ), - onPressed: onSettingsPressed, - ); - final chatOverlayButton = Observer( builder: (_) => IconButton( tooltip: videoStore.settingsStore.fullScreenChatOverlay ? 'Hide chat overlay' : 'Show chat overlay', - onPressed: () => videoStore.settingsStore.fullScreenChatOverlay = !videoStore.settingsStore.fullScreenChatOverlay, - icon: videoStore.settingsStore.fullScreenChatOverlay ? const Icon(Icons.chat_bubble_outline) : const Icon(Icons.chat_bubble), + onPressed: () => + videoStore.settingsStore.fullScreenChatOverlay = !videoStore.settingsStore.fullScreenChatOverlay, + icon: videoStore.settingsStore.fullScreenChatOverlay + ? const Icon(Icons.chat_rounded) + : const Icon(Icons.chat_outlined), color: Colors.white, ), ); - final refreshButton = IconButton( - tooltip: 'Refresh', - icon: const Icon( - Icons.refresh, - color: Colors.white, + final refreshButton = Tooltip( + message: 'Refresh', + preferBelow: false, + child: IconButton( + icon: const Icon( + Icons.refresh_rounded, + color: Colors.white, + ), + onPressed: videoStore.handleRefresh, ), - onPressed: videoStore.handleRefresh, ); - final fullScreenButton = IconButton( - tooltip: videoStore.settingsStore.fullScreen ? 'Exit fullscreen mode' : 'Enter fullscreen mode', - icon: videoStore.settingsStore.fullScreen - ? const Icon( - Icons.fullscreen_exit, - color: Colors.white, - ) - : const Icon( - Icons.fullscreen, - color: Colors.white, - ), - onPressed: () => videoStore.settingsStore.fullScreen = !videoStore.settingsStore.fullScreen, + final fullScreenButton = Tooltip( + message: videoStore.settingsStore.fullScreen ? 'Exit fullscreen mode' : 'Enter fullscreen mode', + preferBelow: false, + child: IconButton( + icon: Icon( + videoStore.settingsStore.fullScreen ? Icons.fullscreen_exit_rounded : Icons.fullscreen_rounded, + color: Colors.white, + ), + onPressed: () => videoStore.settingsStore.fullScreen = !videoStore.settingsStore.fullScreen, + ), ); - final sleepTimerButton = IconButton( - tooltip: 'Sleep timer', - icon: const Icon( - Icons.timer, - color: Colors.white, - ), - onPressed: () => _showSleepTimerDialog(context), - ); - - final rotateButton = IconButton( - tooltip: orientation == Orientation.portrait ? 'Enter landscape mode' : 'Exit landscape mode', - icon: const Icon( - Icons.screen_rotation, - color: Colors.white, - ), - onPressed: () { - if (orientation == Orientation.portrait) { - if (Platform.isIOS) { - SystemChrome.setPreferredOrientations([ - DeviceOrientation.landscapeRight, - ]); - SystemChrome.setPreferredOrientations([]); + final rotateButton = Tooltip( + message: orientation == Orientation.portrait ? 'Enter landscape mode' : 'Exit landscape mode', + preferBelow: false, + child: IconButton( + icon: const Icon( + Icons.screen_rotation_rounded, + color: Colors.white, + ), + onPressed: () { + if (orientation == Orientation.portrait) { + if (Platform.isIOS) { + SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeRight, + ]); + SystemChrome.setPreferredOrientations([]); + } else { + SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeRight, + DeviceOrientation.landscapeLeft, + ]); + } } else { SystemChrome.setPreferredOrientations([ - DeviceOrientation.landscapeRight, - DeviceOrientation.landscapeLeft, + DeviceOrientation.portraitUp, ]); + SystemChrome.setPreferredOrientations([]); } - } else { - SystemChrome.setPreferredOrientations([ - DeviceOrientation.portraitUp, - ]); - SystemChrome.setPreferredOrientations([]); - } - }, + }, + ), ); final streamInfo = videoStore.streamInfo; @@ -194,7 +112,6 @@ class VideoOverlay extends StatelessWidget { backButton, const Spacer(), if (videoStore.settingsStore.fullScreen && orientation == Orientation.landscape) chatOverlayButton, - settingsButton, ], ), Align( @@ -212,11 +129,6 @@ class VideoOverlay extends StatelessWidget { ); } - final streamTitle = videoStore.streamInfo!.title.trim(); - final category = videoStore.streamInfo!.gameName.isNotEmpty ? videoStore.streamInfo!.gameName : 'No Category'; - - final streamerName = regexEnglish.hasMatch(streamInfo.userName) ? streamInfo.userName : '${streamInfo.userName} (${streamInfo.userLogin})'; - return Observer( builder: (context) { return Stack( @@ -225,72 +137,30 @@ class VideoOverlay extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ backButton, - Expanded( - child: Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - ProfilePicture( - userLogin: streamInfo.userLogin, - radius: 10, - ), - const SizedBox(width: 5), - Flexible( - child: Tooltip( - message: streamerName, - child: Text( - streamerName, - style: const TextStyle( - fontWeight: FontWeight.bold, - color: Colors.white, - fontSize: 16, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ), - ], - ), - const SizedBox(height: 5.0), - Tooltip( - message: streamTitle, - child: Text( - streamTitle, - maxLines: orientation == Orientation.portrait ? 1 : 3, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w500, - ), - ), - ), - ], + if (orientation == Orientation.landscape) + Expanded( + child: VideoBar( + streamInfo: streamInfo, + titleTextColor: Colors.white, + subtitleTextColor: Colors.white, + subtitleTextWeight: FontWeight.w500, ), ), - ), if (videoStore.settingsStore.fullScreen && orientation == Orientation.landscape) chatOverlayButton, - sleepTimerButton, - settingsButton, ], ), Center( - child: IconButton( - tooltip: videoStore.paused ? 'Play' : 'Pause', - iconSize: 50.0, - icon: videoStore.paused - ? const Icon( - Icons.play_arrow, - color: Colors.white, - ) - : const Icon( - Icons.pause, - color: Colors.white, - ), - onPressed: videoStore.handlePausePlay, + child: Tooltip( + message: videoStore.paused ? 'Play' : 'Pause', + preferBelow: false, + child: IconButton( + iconSize: 50.0, + icon: Icon( + videoStore.paused ? Icons.play_arrow_rounded : Icons.pause_rounded, + color: Colors.white, + ), + onPressed: videoStore.handlePausePlay, + ), ), ), Align( @@ -300,62 +170,62 @@ class VideoOverlay extends StatelessWidget { children: [ Expanded( child: Padding( - padding: const EdgeInsets.only(bottom: 8.0, left: 8.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.start, + padding: const EdgeInsets.only(left: 10.0, bottom: 12.0), + child: Row( children: [ - Row( - children: [ - const Icon( - Icons.games, - color: Colors.white, - size: 14, - ), - const SizedBox(width: 5), - Flexible( - child: Tooltip( - message: category, - preferBelow: false, - child: Text( - category, - style: const TextStyle( - fontWeight: FontWeight.w500, - color: Colors.white, + Tooltip( + message: 'Stream uptime', + preferBelow: false, + child: Row( + children: [ + const Padding( + padding: EdgeInsets.all(2.0), + child: Icon( + Icons.circle, + color: Colors.red, + size: 10, + ), + ), + const SizedBox(width: 3.0), + Uptime( + startTime: streamInfo.startedAt, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + const SizedBox(width: 10), + Tooltip( + message: 'Viewer count', + preferBelow: false, + child: GestureDetector( + onTap: () => showModalBottomSheet( + backgroundColor: Colors.transparent, + isScrollControlled: true, + context: context, + builder: (context) => FrostyBottomSheet( + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.8, + child: GestureDetector( + onTap: FocusScope.of(context).unfocus, + child: ChattersList( + chatDetailsStore: chatStore.chatDetailsStore, + chatStore: chatStore, + userLogin: streamInfo.userLogin, + ), ), - overflow: TextOverflow.ellipsis, ), ), ), - ], - ), - const SizedBox(height: 5), - Row( - children: [ - Row( + child: Row( children: [ const Icon( - Icons.circle, - color: Colors.red, + Icons.visibility, size: 14, - ), - const SizedBox(width: 5), - Uptime( - startTime: streamInfo.startedAt, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - const SizedBox(width: 10), - Row( - children: [ - const Icon( - Icons.people, color: Colors.white, - size: 14, ), const SizedBox(width: 5), Text( @@ -367,19 +237,22 @@ class VideoOverlay extends StatelessWidget { ), ], ), - ], + ), ), ], ), ), ), - IconButton( - tooltip: 'Picture-in-picture', - icon: const Icon( - Icons.picture_in_picture_alt_rounded, - color: Colors.white, + Tooltip( + message: 'Enter picture-in-picture', + preferBelow: false, + child: IconButton( + icon: const Icon( + Icons.picture_in_picture_alt_rounded, + color: Colors.white, + ), + onPressed: videoStore.requestPictureInPicture, ), - onPressed: videoStore.requestPictureInPicture, ), refreshButton, if (!videoStore.isIPad) rotateButton, diff --git a/lib/screens/channel/video/video_store.dart b/lib/screens/channel/video/video_store.dart index cad382e5..8628d636 100644 --- a/lib/screens/channel/video/video_store.dart +++ b/lib/screens/channel/video/video_store.dart @@ -32,9 +32,6 @@ abstract class VideoStoreBase with Store { /// The webview controller used for injecting JavaScript to control the webview and video player. WebViewController? controller; - /// The current timer for the sleep timer if active. - Timer? sleepTimer; - /// The timer that handles hiding the overlay automatically late Timer _overlayTimer; @@ -69,18 +66,6 @@ abstract class VideoStoreBase with Store { return NavigationDecision.prevent; } - /// The amount of hours the sleep timer is set to. - @observable - var sleepHours = 0; - - /// The amount of minutes the sleep timer is set to. - @observable - var sleepMinutes = 0; - - /// The time remaining for the sleep timer. - @observable - var timeRemaining = const Duration(); - /// If the video is currently paused. /// /// Does not pause or play the video, only used for rendering state of the overlay. @@ -135,8 +120,10 @@ abstract class VideoStoreBase with Store { Future initVideo() async { // Add event listeners to notify the JavaScript channels when the video plays and pauses. try { - controller?.runJavascript('document.getElementsByTagName("video")[0].addEventListener("pause", () => VideoPause.postMessage("video paused"));'); - controller?.runJavascript('document.getElementsByTagName("video")[0].addEventListener("playing", () => VideoPlaying.postMessage("video playing"));'); + controller?.runJavascript( + 'document.getElementsByTagName("video")[0].addEventListener("pause", () => VideoPause.postMessage("video paused"));'); + controller?.runJavascript( + 'document.getElementsByTagName("video")[0].addEventListener("playing", () => VideoPlaying.postMessage("video playing"));'); } catch (e) { debugPrint(e.toString()); } @@ -186,17 +173,6 @@ abstract class VideoStoreBase with Store { } } - /// Handles toggling "minimal mode" on the overlay. - /// - /// "Minimal mode" is when only the channel name is visible on the bottom-left of the overlay. - @action - void handleExpand() { - settingsStore.expandInfo = !settingsStore.expandInfo; - - _overlayTimer.cancel(); - _overlayTimer = Timer(const Duration(seconds: 5), () => _overlayVisible = false); - } - /// Handles the toggle overlay options. /// /// The toggle overlay option allows switching between the custom and Twitch's overlay by long-pressing the overlay. @@ -224,47 +200,6 @@ abstract class VideoStoreBase with Store { updateStreamInfo(); } - /// Updates the sleep timer with [sleepHours] and [sleepMinutes]. - /// Calls [onTimerFinished] when the sleep timer completes. - @action - void updateSleepTimer({required void Function() onTimerFinished}) { - // If hours and minutes are 0, do nothing. - if (sleepHours == 0 && sleepMinutes == 0) return; - - // If there is an ongoing timer, cancel it since it'll be replaced. - if (sleepTimer != null) cancelSleepTimer(); - - // Update the new time remaining - timeRemaining = Duration(hours: sleepHours, minutes: sleepMinutes); - - // Reset the hours and minutes in the dropdown buttons. - sleepHours = 0; - sleepMinutes = 0; - - // Set a periodic timer that will update the time remaining every second. - sleepTimer = Timer.periodic( - const Duration(seconds: 1), - (timer) { - // If the timer is up, cancel the timer and exit the app. - if (timeRemaining.inSeconds == 0) { - timer.cancel(); - onTimerFinished(); - return; - } - - // Decrement the time remaining. - timeRemaining = Duration(seconds: timeRemaining.inSeconds - 1); - }, - ); - } - - /// Cancels the sleep timer and resets the time remaining. - @action - void cancelSleepTimer() { - sleepTimer?.cancel(); - timeRemaining = const Duration(); - } - /// Play or pause the video depending on the current state of [_paused]. void handlePausePlay() { try { @@ -304,6 +239,5 @@ abstract class VideoStoreBase with Store { if (Platform.isIOS) controller?.reload(); _disposeOverlayReaction(); - sleepTimer?.cancel(); } } diff --git a/lib/screens/channel/video/video_store.g.dart b/lib/screens/channel/video/video_store.g.dart index 8a8a0df8..549f0cfc 100644 --- a/lib/screens/channel/video/video_store.g.dart +++ b/lib/screens/channel/video/video_store.g.dart @@ -17,54 +17,6 @@ mixin _$VideoStore on VideoStoreBase, Store { name: 'VideoStoreBase.videoUrl')) .value; - late final _$sleepHoursAtom = - Atom(name: 'VideoStoreBase.sleepHours', context: context); - - @override - int get sleepHours { - _$sleepHoursAtom.reportRead(); - return super.sleepHours; - } - - @override - set sleepHours(int value) { - _$sleepHoursAtom.reportWrite(value, super.sleepHours, () { - super.sleepHours = value; - }); - } - - late final _$sleepMinutesAtom = - Atom(name: 'VideoStoreBase.sleepMinutes', context: context); - - @override - int get sleepMinutes { - _$sleepMinutesAtom.reportRead(); - return super.sleepMinutes; - } - - @override - set sleepMinutes(int value) { - _$sleepMinutesAtom.reportWrite(value, super.sleepMinutes, () { - super.sleepMinutes = value; - }); - } - - late final _$timeRemainingAtom = - Atom(name: 'VideoStoreBase.timeRemaining', context: context); - - @override - Duration get timeRemaining { - _$timeRemainingAtom.reportRead(); - return super.timeRemaining; - } - - @override - set timeRemaining(Duration value) { - _$timeRemainingAtom.reportWrite(value, super.timeRemaining, () { - super.timeRemaining = value; - }); - } - late final _$_pausedAtom = Atom(name: 'VideoStoreBase._paused', context: context); @@ -167,17 +119,6 @@ mixin _$VideoStore on VideoStoreBase, Store { } } - @override - void handleExpand() { - final _$actionInfo = _$VideoStoreBaseActionController.startAction( - name: 'VideoStoreBase.handleExpand'); - try { - return super.handleExpand(); - } finally { - _$VideoStoreBaseActionController.endAction(_$actionInfo); - } - } - @override void handleToggleOverlay() { final _$actionInfo = _$VideoStoreBaseActionController.startAction( @@ -200,28 +141,6 @@ mixin _$VideoStore on VideoStoreBase, Store { } } - @override - void updateSleepTimer({required void Function() onTimerFinished}) { - final _$actionInfo = _$VideoStoreBaseActionController.startAction( - name: 'VideoStoreBase.updateSleepTimer'); - try { - return super.updateSleepTimer(onTimerFinished: onTimerFinished); - } finally { - _$VideoStoreBaseActionController.endAction(_$actionInfo); - } - } - - @override - void cancelSleepTimer() { - final _$actionInfo = _$VideoStoreBaseActionController.startAction( - name: 'VideoStoreBase.cancelSleepTimer'); - try { - return super.cancelSleepTimer(); - } finally { - _$VideoStoreBaseActionController.endAction(_$actionInfo); - } - } - @override void dispose() { final _$actionInfo = _$VideoStoreBaseActionController.startAction( @@ -236,9 +155,6 @@ mixin _$VideoStore on VideoStoreBase, Store { @override String toString() { return ''' -sleepHours: ${sleepHours}, -sleepMinutes: ${sleepMinutes}, -timeRemaining: ${timeRemaining}, videoUrl: ${videoUrl} '''; } diff --git a/lib/screens/home/home.dart b/lib/screens/home/home.dart index a402beb9..8b2c6617 100644 --- a/lib/screens/home/home.dart +++ b/lib/screens/home/home.dart @@ -93,7 +93,7 @@ class _HomeState extends State { actions: [ IconButton( tooltip: 'Settings', - icon: const Icon(Icons.settings), + icon: const Icon(Icons.settings_rounded), onPressed: () => Navigator.push( context, MaterialPageRoute( @@ -104,30 +104,23 @@ class _HomeState extends State { ], ), body: SafeArea( - child: Column( - children: [ - Expanded( - child: Observer( - builder: (_) => IndexedStack( - index: _homeStore.selectedIndex, - children: [ - if (_authStore.isLoggedIn) - StreamsList( - listType: ListType.followed, - scrollController: _homeStore.followedScrollController, - ), - TopSection( - homeStore: _homeStore, - ), - Search( - scrollController: _homeStore.searchScrollController, - ), - ], + child: Observer( + builder: (_) => IndexedStack( + index: _homeStore.selectedIndex, + children: [ + if (_authStore.isLoggedIn) + StreamsList( + listType: ListType.followed, + scrollController: _homeStore.followedScrollController, ), + TopSection( + homeStore: _homeStore, ), - ), - const Divider(height: 1.0, thickness: 1.0), - ], + Search( + scrollController: _homeStore.searchScrollController, + ), + ], + ), ), ), bottomNavigationBar: Observer( @@ -135,20 +128,24 @@ class _HomeState extends State { unselectedFontSize: 12.0, selectedFontSize: 12.0, type: BottomNavigationBarType.fixed, + selectedLabelStyle: const TextStyle(fontWeight: FontWeight.w500), + unselectedLabelStyle: const TextStyle(fontWeight: FontWeight.w500), items: [ if (_authStore.isLoggedIn) - const BottomNavigationBarItem( - icon: Icon(Icons.favorite), + BottomNavigationBarItem( + icon: _homeStore.selectedIndex == 0 + ? const Icon(Icons.favorite_rounded) + : const Icon(Icons.favorite_border_rounded), label: 'Following', tooltip: 'Followed streams', ), const BottomNavigationBarItem( - icon: Icon(Icons.arrow_upward), + icon: Icon(Icons.arrow_upward_rounded), label: 'Top', tooltip: 'Top streams and categories', ), const BottomNavigationBarItem( - icon: Icon(Icons.search), + icon: Icon(Icons.search_rounded), label: 'Search', tooltip: 'Search for channels and categories', ), diff --git a/lib/screens/home/home_store.dart b/lib/screens/home/home_store.dart index f07acce0..ee3a89e9 100644 --- a/lib/screens/home/home_store.dart +++ b/lib/screens/home/home_store.dart @@ -51,42 +51,42 @@ abstract class HomeStoreBase with Store { // Use different logic if logged in/out since there will be one less tab when logged out. if (authStore.isLoggedIn) { - if (index == 0) { + if (index == 0 && followedScrollController.hasClients) { // If on the followed tab and tapping the followed tab, scroll to the top. followedScrollController.animateTo( 0.0, duration: duration, - curve: Curves.easeOutCubic, + curve: Curves.easeOut, ); - } else if (index == 1) { + } else if (index == 1 && topSectionScrollControllers[topSectionCurrentIndex].hasClients) { // If on the top section, scroll to the top of the tab based on the current top tab. topSectionScrollControllers[topSectionCurrentIndex].animateTo( 0.0, duration: duration, - curve: Curves.easeOutCubic, + curve: Curves.easeOut, ); - } else { + } else if (searchScrollController.hasClients) { // If on the search tab and tapping the search tab, scroll to the top. searchScrollController.animateTo( 0.0, duration: duration, - curve: Curves.easeOutCubic, + curve: Curves.easeOut, ); } } else { - if (index == 0) { + if (index == 0 && topSectionScrollControllers[topSectionCurrentIndex].hasClients) { // If on the top section, scroll to the top of the tab based on the current top tab. topSectionScrollControllers[topSectionCurrentIndex].animateTo( 0.0, duration: duration, - curve: Curves.easeOutCubic, + curve: Curves.easeOut, ); - } else { + } else if (searchScrollController.hasClients) { // If on the search tab and tapping the search tab, scroll to the top. searchScrollController.animateTo( 0.0, duration: duration, - curve: Curves.easeOutCubic, + curve: Curves.easeOut, ); } } diff --git a/lib/screens/home/search/search.dart b/lib/screens/home/search/search.dart index 5b2ff8bd..8c18ec21 100644 --- a/lib/screens/home/search/search.dart +++ b/lib/screens/home/search/search.dart @@ -7,6 +7,7 @@ import 'package:frosty/screens/home/search/search_results_channels.dart'; import 'package:frosty/screens/home/search/search_store.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/widgets/alert_message.dart'; +import 'package:frosty/widgets/list_tile.dart'; import 'package:frosty/widgets/section_header.dart'; import 'package:provider/provider.dart'; @@ -45,12 +46,12 @@ class _SearchState extends State { focusNode: _searchStore.textFieldFocusNode, autocorrect: false, decoration: InputDecoration( - contentPadding: const EdgeInsets.all(0.0), + contentPadding: EdgeInsets.zero, + prefixIcon: const Icon(Icons.search_rounded), hintText: 'Find a channel or category', - prefixIcon: const Icon(Icons.search), suffixIcon: _searchStore.textFieldFocusNode.hasFocus || _searchStore.searchText.isNotEmpty ? IconButton( - icon: const Icon(Icons.clear), + icon: const Icon(Icons.close_rounded), tooltip: _searchStore.searchText.isEmpty ? 'Cancel' : 'Clear', onPressed: () { if (_searchStore.searchText.isEmpty) _searchStore.textFieldFocusNode.unfocus(); @@ -69,10 +70,7 @@ class _SearchState extends State { builder: (context) { if (_searchStore.textEditingController.text.isEmpty) { if (_searchStore.searchHistory.isEmpty) { - return const AlertMessage( - message: 'No recent searches', - icon: Icons.search_off, - ); + return const AlertMessage(message: 'No recent searches'); } return Column( @@ -87,19 +85,22 @@ class _SearchState extends State { controller: widget.scrollController, children: _searchStore.searchHistory .mapIndexed( - (index, searchTerm) => ListTile( - leading: const Icon(Icons.history), - title: Text(searchTerm), - trailing: IconButton( - tooltip: 'Remove', - icon: const Icon(Icons.cancel), - onPressed: () => _searchStore.searchHistory.removeAt(index), + (index, searchTerm) => FrostyListTile( + leading: const Icon(Icons.history_rounded), + title: searchTerm, + trailing: Tooltip( + message: 'Remove', + preferBelow: false, + child: IconButton( + icon: const Icon(Icons.close_rounded), + onPressed: () => _searchStore.searchHistory.removeAt(index), + ), ), onTap: () { _searchStore.textEditingController.text = searchTerm; _searchStore.handleQuery(searchTerm); - _searchStore.textEditingController.selection = - TextSelection.fromPosition(TextPosition(offset: _searchStore.textEditingController.text.length)); + _searchStore.textEditingController.selection = TextSelection.fromPosition( + TextPosition(offset: _searchStore.textEditingController.text.length)); }, ), ) diff --git a/lib/screens/home/search/search_results_categories.dart b/lib/screens/home/search/search_results_categories.dart index b976ec12..1415e07d 100644 --- a/lib/screens/home/search/search_results_categories.dart +++ b/lib/screens/home/search/search_results_categories.dart @@ -32,10 +32,7 @@ class SearchResultsCategories extends StatelessWidget { return const SliverToBoxAdapter( child: SizedBox( height: 100.0, - child: AlertMessage( - message: 'Failed to get categories', - icon: Icons.error, - ), + child: AlertMessage(message: 'Failed to get categories'), ), ); case FutureStatus.fulfilled: @@ -45,10 +42,7 @@ class SearchResultsCategories extends StatelessWidget { return const SliverToBoxAdapter( child: SizedBox( height: 100.0, - child: AlertMessage( - message: 'Failed to get categories', - icon: Icons.error, - ), + child: AlertMessage(message: 'Failed to get categories'), ), ); } diff --git a/lib/screens/home/search/search_results_channels.dart b/lib/screens/home/search/search_results_channels.dart index b0951fc3..68894b9e 100644 --- a/lib/screens/home/search/search_results_channels.dart +++ b/lib/screens/home/search/search_results_channels.dart @@ -8,8 +8,10 @@ import 'package:frosty/screens/home/search/search_store.dart'; import 'package:frosty/widgets/alert_message.dart'; import 'package:frosty/widgets/animate_scale.dart'; import 'package:frosty/widgets/block_report_modal.dart'; +import 'package:frosty/widgets/list_tile.dart'; import 'package:frosty/widgets/loading_indicator.dart'; import 'package:frosty/widgets/profile_picture.dart'; +import 'package:frosty/widgets/uptime.dart'; import 'package:mobx/mobx.dart'; class SearchResultsChannels extends StatefulWidget { @@ -44,10 +46,7 @@ class _SearchResultsChannelsState extends State { ); } catch (error) { final snackBar = SnackBar( - content: AlertMessage( - message: error.toString(), - icon: Icons.error, - ), + content: AlertMessage(message: error.toString()), behavior: SnackBarBehavior.floating, ); @@ -72,79 +71,77 @@ class _SearchResultsChannelsState extends State { return const SliverToBoxAdapter( child: SizedBox( height: 100.0, - child: AlertMessage( - message: 'Failed to get channels', - icon: Icons.error, - ), + child: AlertMessage(message: 'Failed to get channels'), ), ); case FutureStatus.fulfilled: - final results = (future.result as List) - .where((channel) => !widget.searchStore.authStore.user.blockedUsers.map((blockedUser) => blockedUser.userId).contains(channel.id)); + final results = (future.result as List).where((channel) => !widget + .searchStore.authStore.user.blockedUsers + .map((blockedUser) => blockedUser.userId) + .contains(channel.id)); return SliverList( - delegate: SliverChildListDelegate.fixed([ - ...results.map( - (channel) { - final displayName = - regexEnglish.hasMatch(channel.displayName) ? channel.displayName : '${channel.displayName} (${channel.broadcasterLogin})'; + delegate: SliverChildListDelegate.fixed( + [ + ...results.map( + (channel) { + final displayName = regexEnglish.hasMatch(channel.displayName) + ? channel.displayName + : '${channel.displayName} (${channel.broadcasterLogin})'; - return AnimateScale( - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => VideoChat( - userId: channel.id, - userName: channel.displayName, - userLogin: channel.broadcasterLogin, + return AnimateScale( + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => VideoChat( + userId: channel.id, + userName: channel.displayName, + userLogin: channel.broadcasterLogin, + ), ), ), - ), - onLongPress: () { - HapticFeedback.lightImpact(); + onLongPress: () { + HapticFeedback.lightImpact(); - showModalBottomSheet( - backgroundColor: Colors.transparent, - context: context, - builder: (context) => BlockReportModal( - authStore: widget.searchStore.authStore, - name: displayName, - userLogin: channel.broadcasterLogin, - userId: channel.id, - ), - ); - }, - child: ListTile( - title: Text(displayName), - leading: ProfilePicture(userLogin: channel.broadcasterLogin), - trailing: channel.isLive - ? ClipRRect( - borderRadius: BorderRadius.circular(5.0), - child: Container( - color: const Color(0xFFF44336), - padding: const EdgeInsets.all(10.0), - child: const Text( - 'LIVE', - style: TextStyle( - fontWeight: FontWeight.bold, - color: Colors.white, + showModalBottomSheet( + backgroundColor: Colors.transparent, + context: context, + builder: (context) => BlockReportModal( + authStore: widget.searchStore.authStore, + name: displayName, + userLogin: channel.broadcasterLogin, + userId: channel.id, + ), + ); + }, + child: FrostyListTile( + isThreeLine: false, + title: displayName, + leading: ProfilePicture(userLogin: channel.broadcasterLogin), + subtitle: channel.isLive + ? Row( + children: [ + const Icon( + Icons.circle, + color: Colors.red, + size: 10, ), - ), - ), - ) - : null, - subtitle: - channel.isLive ? Text('Uptime: ${DateTime.now().difference(DateTime.parse(channel.startedAt)).toString().split('.')[0]}') : null, - ), - ); - }, - ), - ListTile( - title: Text('Go to channel "${widget.query}"'), - onTap: () => _handleSearch(context, widget.query), - trailing: Icon(Icons.adaptive.arrow_forward), - ) - ]), + const SizedBox(width: 5), + Uptime(startTime: channel.startedAt), + ], + ) + : null, + ), + ); + }, + ), + FrostyListTile( + title: 'Go to channel "${widget.query}"', + onTap: () => _handleSearch(context, widget.query), + trailing: const Icon(Icons.chevron_right_rounded), + ) + ], + ), ); } }, diff --git a/lib/screens/home/stream_list/large_stream_card.dart b/lib/screens/home/stream_list/large_stream_card.dart new file mode 100644 index 00000000..d21b010e --- /dev/null +++ b/lib/screens/home/stream_list/large_stream_card.dart @@ -0,0 +1,172 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:frosty/constants.dart'; +import 'package:frosty/main.dart'; +import 'package:frosty/models/stream.dart'; +import 'package:frosty/screens/channel/channel.dart'; +import 'package:frosty/screens/channel/video/video_bar.dart'; +import 'package:frosty/screens/settings/stores/auth_store.dart'; +import 'package:frosty/widgets/animate_scale.dart'; +import 'package:frosty/widgets/block_report_modal.dart'; +import 'package:frosty/widgets/cached_image.dart'; +import 'package:frosty/widgets/loading_indicator.dart'; +import 'package:frosty/widgets/uptime.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; + +class LargeStreamCard extends StatelessWidget { + final StreamTwitch streamInfo; + final bool showThumbnail; + final bool showCategory; + + const LargeStreamCard({ + Key? key, + required this.streamInfo, + required this.showThumbnail, + this.showCategory = true, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + // Get a new URL for the thumbnail every 5 minutes so that the image is updated on refresh. + // This method adds a random value to the end of the URL to override the cached image. + final time = DateTime.now(); + final cacheUrlExtension = time.day.toString() + time.hour.toString() + (time.minute ~/ 5).toString(); + + // Calculate the width and height of the thumbnail based on the device width and the stream card size setting. + // Constraint the resolution to 1920x1080 since that's the max resolution of the Twitch API. + final size = MediaQuery.of(context).size; + final pixelRatio = MediaQuery.of(context).devicePixelRatio; + final thumbnailWidth = min((size.width * pixelRatio) ~/ 1, 1920); + final thumbnailHeight = min((thumbnailWidth * (9 / 16)).toInt(), 1080); + + final thumbnail = ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(10)), + child: Stack( + alignment: Alignment.bottomLeft, + children: [ + Container( + foregroundDecoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: [0.8, 1.0], + colors: [ + Colors.transparent, + Colors.black, + ], + ), + ), + child: AspectRatio( + aspectRatio: 16 / 9, + child: FrostyCachedNetworkImage( + imageUrl: + streamInfo.thumbnailUrl.replaceFirst('-{width}x{height}', '-${thumbnailWidth}x$thumbnailHeight') + + cacheUrlExtension, + placeholder: (context, url) => const ColoredBox(color: lightGray, child: LoadingIndicator()), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(left: 10.0, bottom: 12.0), + child: Row( + children: [ + Tooltip( + message: 'Stream uptime', + preferBelow: false, + child: Row( + children: [ + const Padding( + padding: EdgeInsets.all(2.0), + child: Icon( + Icons.circle, + color: Colors.red, + size: 10, + ), + ), + const SizedBox(width: 3.0), + Uptime( + startTime: streamInfo.startedAt, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + const SizedBox(width: 10.0), + Tooltip( + message: 'Viewer count', + preferBelow: false, + child: Row( + children: [ + const Icon( + Icons.visibility, + size: 14, + color: Colors.white, + ), + const SizedBox(width: 5), + Text( + NumberFormat().format(streamInfo.viewerCount), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ) + ], + ), + ), + ], + ), + ); + + final streamerName = regexEnglish.hasMatch(streamInfo.userName) + ? streamInfo.userName + : '${streamInfo.userName} (${streamInfo.userLogin})'; + + return AnimateScale( + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => VideoChat( + userId: streamInfo.userId, + userName: streamInfo.userName, + userLogin: streamInfo.userLogin, + ), + ), + ), + onLongPress: () { + HapticFeedback.mediumImpact(); + + showModalBottomSheet( + backgroundColor: Colors.transparent, + context: context, + builder: (context) => BlockReportModal( + authStore: context.read(), + name: streamerName, + userLogin: streamInfo.userLogin, + userId: streamInfo.userId, + ), + ); + }, + child: Padding( + padding: EdgeInsets.symmetric(vertical: showThumbnail ? 10.0 : 5.0, horizontal: 15.0), + child: Column( + children: [ + if (showThumbnail) thumbnail, + VideoBar( + streamInfo: streamInfo, + showCategory: showCategory, + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/home/stream_list/stream_card.dart b/lib/screens/home/stream_list/stream_card.dart index 9c1379ac..564a8d51 100644 --- a/lib/screens/home/stream_list/stream_card.dart +++ b/lib/screens/home/stream_list/stream_card.dart @@ -1,15 +1,16 @@ import 'dart:math'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:frosty/constants.dart'; +import 'package:frosty/main.dart'; import 'package:frosty/models/stream.dart'; import 'package:frosty/screens/channel/channel.dart'; import 'package:frosty/screens/home/top/categories/category_streams.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/widgets/animate_scale.dart'; import 'package:frosty/widgets/block_report_modal.dart'; +import 'package:frosty/widgets/cached_image.dart'; import 'package:frosty/widgets/loading_indicator.dart'; import 'package:frosty/widgets/profile_picture.dart'; import 'package:frosty/widgets/uptime.dart'; @@ -19,17 +20,13 @@ import 'package:provider/provider.dart'; /// A tappable card widget that displays a stream's thumbnail and details. class StreamCard extends StatelessWidget { final StreamTwitch streamInfo; - final bool showUptime; final bool showThumbnail; - final bool large; final bool showCategory; const StreamCard({ Key? key, required this.streamInfo, - required this.showUptime, required this.showThumbnail, - required this.large, this.showCategory = true, }) : super(key: key); @@ -44,84 +41,55 @@ class StreamCard extends StatelessWidget { // Constraint the resolution to 1920x1080 since that's the max resolution of the Twitch API. final size = MediaQuery.of(context).size; final pixelRatio = MediaQuery.of(context).devicePixelRatio; - final thumbnailWidth = min((size.width * pixelRatio) ~/ (large ? 1 : 3), 1920); + final thumbnailWidth = min((size.width * pixelRatio) ~/ 3, 1920); final thumbnailHeight = min((thumbnailWidth * (9 / 16)).toInt(), 1080); - final image = AspectRatio( + final thumbnail = AspectRatio( aspectRatio: 16 / 9, - child: CachedNetworkImage( - imageUrl: streamInfo.thumbnailUrl.replaceFirst('-{width}x{height}', '-${thumbnailWidth}x$thumbnailHeight') + cacheUrlExtension, - placeholder: (context, url) => const LoadingIndicator(), - useOldImageOnUrlChange: true, + child: FrostyCachedNetworkImage( + imageUrl: streamInfo.thumbnailUrl.replaceFirst('-{width}x{height}', '-${thumbnailWidth}x$thumbnailHeight') + + cacheUrlExtension, + placeholder: (context, url) => const ColoredBox(color: lightGray, child: LoadingIndicator()), ), ); - final thumbnail = large - ? Container( - foregroundDecoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black, - ], - ), - ), - child: image, - ) - : image; + final streamerName = regexEnglish.hasMatch(streamInfo.userName) + ? streamInfo.userName + : '${streamInfo.userName} (${streamInfo.userLogin})'; - final streamerName = regexEnglish.hasMatch(streamInfo.userName) ? streamInfo.userName : '${streamInfo.userName} (${streamInfo.userLogin})'; + const subFontSize = 14.0; - final subFontSize = large ? 16.0 : 14.0; - - final fontColor = large ? Colors.white : DefaultTextStyle.of(context).style.color; + final fontColor = DefaultTextStyle.of(context).style.color; final imageSection = ClipRRect( - borderRadius: large ? const BorderRadius.all(Radius.circular(10.0)) : const BorderRadius.all(Radius.circular(5.0)), - child: showUptime - ? Stack( - alignment: AlignmentDirectional.bottomEnd, - children: [ - thumbnail, - if (large) - Padding( - padding: const EdgeInsets.all(10.0), - child: Uptime( - startTime: streamInfo.startedAt, - style: TextStyle( - fontSize: 16.0, - color: Colors.white.withOpacity(0.8), - ), - ), - ) - else - Container( - padding: const EdgeInsets.all(2.0), - decoration: const BoxDecoration( - color: Color.fromRGBO(0, 0, 0, 0.5), - borderRadius: BorderRadius.all( - Radius.circular(3.0), - ), - ), - margin: const EdgeInsets.all(2.0), - child: Uptime( - startTime: streamInfo.startedAt, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: Colors.white, - ), - ), - ) - ], + borderRadius: const BorderRadius.all(Radius.circular(5.0)), + child: Stack( + alignment: AlignmentDirectional.bottomEnd, + children: [ + thumbnail, + Container( + padding: const EdgeInsets.all(2.0), + decoration: const BoxDecoration( + color: Color.fromRGBO(0, 0, 0, 0.5), + borderRadius: BorderRadius.all( + Radius.circular(3.0), + ), + ), + margin: const EdgeInsets.all(2.0), + child: Uptime( + startTime: streamInfo.startedAt, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + ), ) - : thumbnail, - ); + ], + )); final streamInfoSection = Padding( - padding: large ? const EdgeInsets.all(10.0) : const EdgeInsets.only(left: 10.0), + padding: const EdgeInsets.only(left: 10.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -134,16 +102,16 @@ class StreamCard extends StatelessWidget { const SizedBox(width: 5.0), Flexible( child: Tooltip( - message: streamerName, + message: 'Streamer: $streamerName', preferBelow: false, child: Text( streamerName, + overflow: TextOverflow.ellipsis, style: TextStyle( - fontSize: large ? 20.0 : 16.0, + fontSize: 16.0, fontWeight: FontWeight.w600, color: fontColor, ), - overflow: TextOverflow.ellipsis, ), ), ), @@ -151,7 +119,7 @@ class StreamCard extends StatelessWidget { ), const SizedBox(height: 5.0), Tooltip( - message: streamInfo.title.trim(), + message: 'Title: ${streamInfo.title.trim()}', preferBelow: false, padding: const EdgeInsets.all(10.0), child: Text( @@ -178,7 +146,7 @@ class StreamCard extends StatelessWidget { ) : null, child: Tooltip( - message: streamInfo.gameName, + message: 'Category: ${streamInfo.gameName.isNotEmpty ? streamInfo.gameName : 'None'}', preferBelow: false, child: Text( streamInfo.gameName.isNotEmpty ? streamInfo.gameName : 'No Category', @@ -230,24 +198,19 @@ class StreamCard extends StatelessWidget { }, child: Padding( padding: EdgeInsets.symmetric(vertical: 10.0, horizontal: showThumbnail ? 15.0 : 5.0), - child: large - ? Stack( - alignment: AlignmentDirectional.bottomStart, - children: [if (showThumbnail) imageSection, streamInfoSection], - ) - : Row( - children: [ - if (showThumbnail) - Flexible( - flex: 1, - child: imageSection, - ), - Flexible( - flex: 2, - child: streamInfoSection, - ), - ], + child: Row( + children: [ + if (showThumbnail) + Flexible( + flex: 1, + child: imageSection, ), + Flexible( + flex: 2, + child: streamInfoSection, + ), + ], + ), ), ); } diff --git a/lib/screens/home/stream_list/streams_list.dart b/lib/screens/home/stream_list/streams_list.dart index a93adc13..49a098f1 100644 --- a/lib/screens/home/stream_list/streams_list.dart +++ b/lib/screens/home/stream_list/streams_list.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/screens/home/stream_list/large_stream_card.dart'; import 'package:frosty/screens/home/stream_list/stream_card.dart'; import 'package:frosty/screens/home/stream_list/stream_list_store.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; @@ -66,10 +67,7 @@ class _StreamsListState extends State with AutomaticKeepAliveClient if (_listStore.error != null) { final snackBar = SnackBar( - content: AlertMessage( - message: _listStore.error!, - icon: Icons.error, - ), + content: AlertMessage(message: _listStore.error!), behavior: SnackBarBehavior.floating, ); @@ -82,17 +80,15 @@ class _StreamsListState extends State with AutomaticKeepAliveClient Widget? statusWidget; if (_listStore.error != null) { - statusWidget = AlertMessage( - message: _listStore.error!, - icon: Icons.error, - ); + statusWidget = AlertMessage(message: _listStore.error!); } if (_listStore.streams.isEmpty) { if (_listStore.isLoading && _listStore.error == null) { statusWidget = const LoadingIndicator(subtitle: 'Loading streams...'); } else { - statusWidget = AlertMessage(message: widget.listType == ListType.followed ? 'No followed streams' : 'No top streams'); + statusWidget = AlertMessage( + message: widget.listType == ListType.followed ? 'No followed streams' : 'No top streams'); } } @@ -119,12 +115,17 @@ class _StreamsListState extends State with AutomaticKeepAliveClient _listStore.getStreams(); } return Observer( - builder: (context) => StreamCard( - streamInfo: _listStore.streams[index], - showThumbnail: context.read().showThumbnails, - large: context.read().largeStreamCard, - showUptime: context.read().showThumbnailUptime, - ), + builder: (context) => context.read().largeStreamCard + ? LargeStreamCard( + key: ValueKey(_listStore.streams[index].userId), + streamInfo: _listStore.streams[index], + showThumbnail: context.read().showThumbnails, + ) + : StreamCard( + key: ValueKey(_listStore.streams[index].userId), + streamInfo: _listStore.streams[index], + showThumbnail: context.read().showThumbnails, + ), ); }, ); diff --git a/lib/screens/home/top/categories/categories.dart b/lib/screens/home/top/categories/categories.dart index c2f2c1b6..42d9bcf3 100644 --- a/lib/screens/home/top/categories/categories.dart +++ b/lib/screens/home/top/categories/categories.dart @@ -56,10 +56,7 @@ class _CategoriesState extends State with AutomaticKeepAliveClientMi if (_categoriesStore.error != null) { final snackBar = SnackBar( - content: AlertMessage( - message: _categoriesStore.error!, - icon: Icons.error, - ), + content: AlertMessage(message: _categoriesStore.error!), behavior: SnackBarBehavior.floating, ); @@ -72,10 +69,7 @@ class _CategoriesState extends State with AutomaticKeepAliveClientMi Widget? statusWidget; if (_categoriesStore.error != null) { - statusWidget = AlertMessage( - message: _categoriesStore.error!, - icon: Icons.error, - ); + statusWidget = AlertMessage(message: _categoriesStore.error!); } if (_categoriesStore.categories.isEmpty) { @@ -110,6 +104,7 @@ class _CategoriesState extends State with AutomaticKeepAliveClientMi _categoriesStore.getCategories(); } return CategoryCard( + key: ValueKey(_categoriesStore.categories[index].id), category: _categoriesStore.categories[index], ); }, diff --git a/lib/screens/home/top/categories/category_card.dart b/lib/screens/home/top/categories/category_card.dart index ff17fae2..7ecb831b 100644 --- a/lib/screens/home/top/categories/category_card.dart +++ b/lib/screens/home/top/categories/category_card.dart @@ -1,8 +1,8 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:frosty/main.dart'; import 'package:frosty/models/category.dart'; import 'package:frosty/screens/home/top/categories/category_streams.dart'; -import 'package:frosty/widgets/animate_scale.dart'; +import 'package:frosty/widgets/cached_image.dart'; import 'package:frosty/widgets/loading_indicator.dart'; /// A tappable card widget that displays a category's box art and name under. @@ -22,7 +22,7 @@ class CategoryCard extends StatelessWidget { final artWidth = (size.width * pixelRatio) ~/ 3; final artHeight = (artWidth * (4 / 3)).toInt(); - return AnimateScale( + return InkWell( onTap: () => Navigator.push( context, MaterialPageRoute( @@ -38,12 +38,13 @@ class CategoryCard extends StatelessWidget { children: [ Expanded( child: ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(5.0)), + borderRadius: const BorderRadius.all(Radius.circular(10.0)), child: AspectRatio( aspectRatio: 3 / 4, - child: CachedNetworkImage( - imageUrl: category.boxArtUrl.replaceRange(category.boxArtUrl.lastIndexOf('-') + 1, null, '${artWidth}x$artHeight.jpg'), - placeholder: (context, url) => const LoadingIndicator(), + child: FrostyCachedNetworkImage( + imageUrl: category.boxArtUrl + .replaceRange(category.boxArtUrl.lastIndexOf('-') + 1, null, '${artWidth}x$artHeight.jpg'), + placeholder: (context, url) => const ColoredBox(color: lightGray, child: LoadingIndicator()), ), ), ), diff --git a/lib/screens/home/top/categories/category_streams.dart b/lib/screens/home/top/categories/category_streams.dart index a6a91353..b099a4f2 100644 --- a/lib/screens/home/top/categories/category_streams.dart +++ b/lib/screens/home/top/categories/category_streams.dart @@ -1,14 +1,16 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/main.dart'; import 'package:frosty/models/category.dart'; +import 'package:frosty/screens/home/stream_list/large_stream_card.dart'; import 'package:frosty/screens/home/stream_list/stream_card.dart'; import 'package:frosty/screens/home/stream_list/stream_list_store.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/widgets/alert_message.dart'; +import 'package:frosty/widgets/cached_image.dart'; import 'package:frosty/widgets/loading_indicator.dart'; import 'package:frosty/widgets/scroll_to_top_button.dart'; import 'package:provider/provider.dart'; @@ -57,10 +59,7 @@ class _CategoryStreamsState extends State { if (_listStore.error != null) { final snackBar = SnackBar( - content: AlertMessage( - message: _listStore.error!, - icon: Icons.error, - ), + content: AlertMessage(message: _listStore.error!), behavior: SnackBarBehavior.floating, ); @@ -78,6 +77,10 @@ class _CategoryStreamsState extends State { controller: _listStore.scrollController, slivers: [ SliverAppBar( + leading: IconButton( + icon: Icon(Icons.adaptive.arrow_back_rounded), + onPressed: Navigator.of(context).pop, + ), stretch: true, pinned: true, expandedHeight: MediaQuery.of(context).size.height / 3, @@ -100,13 +103,14 @@ class _CategoryStreamsState extends State { ), builder: (context, AsyncSnapshot snapshot) { return snapshot.hasData - ? CachedNetworkImage( + ? FrostyCachedNetworkImage( imageUrl: snapshot.data!.data.first.boxArtUrl.replaceRange( snapshot.data!.data.first.boxArtUrl.lastIndexOf('-') + 1, null, '${artWidth}x$artHeight.jpg', ), - placeholder: (context, url) => const LoadingIndicator(), + placeholder: (context, url) => + const ColoredBox(color: lightGray, child: LoadingIndicator()), color: const Color.fromRGBO(255, 255, 255, 0.5), colorBlendMode: BlendMode.modulate, fit: BoxFit.cover, @@ -119,10 +123,7 @@ class _CategoryStreamsState extends State { if (_listStore.error != null) SliverFillRemaining( hasScrollBody: false, - child: AlertMessage( - message: _listStore.error!, - icon: Icons.error, - ), + child: AlertMessage(message: _listStore.error!), ) else if (_listStore.streams.isEmpty) if (_listStore.isLoading && _listStore.error == null) @@ -149,13 +150,17 @@ class _CategoryStreamsState extends State { _listStore.getStreams(); } return Observer( - builder: (context) => StreamCard( - streamInfo: _listStore.streams[index], - showUptime: context.read().showThumbnailUptime, - showThumbnail: context.read().showThumbnails, - large: context.read().largeStreamCard, - showCategory: false, - ), + builder: (context) => context.read().largeStreamCard + ? LargeStreamCard( + streamInfo: _listStore.streams[index], + showThumbnail: context.read().showThumbnails, + showCategory: false, + ) + : StreamCard( + streamInfo: _listStore.streams[index], + showThumbnail: context.read().showThumbnails, + showCategory: false, + ), ); }, childCount: _listStore.streams.length, @@ -168,9 +173,11 @@ class _CategoryStreamsState extends State { child: Observer( builder: (context) => AnimatedSwitcher( duration: const Duration(milliseconds: 200), - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, - child: _listStore.showJumpButton ? ScrollToTopButton(scrollController: _listStore.scrollController!) : null, + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: _listStore.showJumpButton + ? ScrollToTopButton(scrollController: _listStore.scrollController!) + : null, ), ), ), diff --git a/lib/screens/home/top/top.dart b/lib/screens/home/top/top.dart index 0ea965cb..8f1afb12 100644 --- a/lib/screens/home/top/top.dart +++ b/lib/screens/home/top/top.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:frosty/main.dart'; import 'package:frosty/screens/home/home_store.dart'; import 'package:frosty/screens/home/stream_list/stream_list_store.dart'; import 'package:frosty/screens/home/stream_list/streams_list.dart'; @@ -28,23 +29,19 @@ class _TopSectionState extends State with SingleTickerProviderStateM @override Widget build(BuildContext context) { return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ TabBar( controller: _tabBarController, - indicatorColor: const Color(0xff9146ff), + labelStyle: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 16.0, + ), + indicatorSize: TabBarIndicatorSize.label, + indicatorColor: purple, tabs: const [ - Tab( - child: Text( - 'Streams', - style: TextStyle(fontWeight: FontWeight.w600), - ), - ), - Tab( - child: Text( - 'Categories', - style: TextStyle(fontWeight: FontWeight.w600), - ), - ), + Tab(child: Text('Streams')), + Tab(child: Text('Categories')), ], ), Expanded( diff --git a/lib/screens/settings/account/account_options.dart b/lib/screens/settings/account/account_options.dart new file mode 100644 index 00000000..c3d2e6a9 --- /dev/null +++ b/lib/screens/settings/account/account_options.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:frosty/screens/settings/account/blocked_users.dart'; +import 'package:frosty/screens/settings/stores/auth_store.dart'; +import 'package:frosty/screens/settings/widgets/settings_tile_route.dart'; +import 'package:frosty/widgets/button.dart'; +import 'package:frosty/widgets/dialog.dart'; +import 'package:frosty/widgets/list_tile.dart'; + +class AccountOptions extends StatelessWidget { + final AuthStore authStore; + + const AccountOptions({Key? key, required this.authStore}) : super(key: key); + + Future _showLogoutDialog(BuildContext context) { + return showDialog( + context: context, + builder: (context) => FrostyDialog( + title: 'Log Out', + message: 'Are you sure you want to log out?', + actions: [ + Button( + onPressed: () { + authStore.logout(); + Navigator.pop(context); + Navigator.pop(context); + }, + child: const Text('Yes'), + ), + Button( + onPressed: Navigator.of(context).pop, + color: Colors.grey, + child: const Text('Cancel'), + ) + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return ListView( + children: [ + SettingsTileRoute( + leading: const Icon(Icons.block_rounded), + title: 'Blocked', + child: BlockedUsers( + authStore: authStore, + ), + ), + FrostyListTile( + leading: const Icon(Icons.logout_rounded), + title: 'Log out', + trailing: const Icon(Icons.chevron_right_rounded), + onTap: () => _showLogoutDialog(context), + ) + ], + ); + } +} diff --git a/lib/screens/settings/account/blocked_users.dart b/lib/screens/settings/account/blocked_users.dart new file mode 100644 index 00000000..b068a6a9 --- /dev/null +++ b/lib/screens/settings/account/blocked_users.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:frosty/constants.dart'; +import 'package:frosty/screens/settings/stores/auth_store.dart'; +import 'package:frosty/widgets/alert_message.dart'; +import 'package:frosty/widgets/button.dart'; +import 'package:frosty/widgets/list_tile.dart'; + +class BlockedUsers extends StatelessWidget { + final AuthStore authStore; + + const BlockedUsers({ + Key? key, + required this.authStore, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return RefreshIndicator( + onRefresh: () async { + HapticFeedback.lightImpact(); + + await authStore.user.refreshBlockedUsers(headers: authStore.headersTwitch); + }, + child: Observer( + builder: (context) { + if (authStore.user.blockedUsers.isEmpty) { + return const Center( + child: AlertMessage( + message: 'No blocked users', + ), + ); + } + return ListView( + children: authStore.user.blockedUsers.map( + (blockedUser) { + final displayName = regexEnglish.hasMatch(blockedUser.displayName) + ? blockedUser.displayName + : '${blockedUser.displayName} (${blockedUser.userLogin})'; + + return FrostyListTile( + title: displayName, + trailing: Button( + color: Colors.red, + onPressed: () => authStore.showBlockDialog( + context, + targetUser: displayName, + targetUserId: blockedUser.userId, + ), + child: const Text('Unblock'), + ), + ); + }, + ).toList(), + ); + }, + ), + ); + } +} diff --git a/lib/screens/settings/account/widgets/profile_card.dart b/lib/screens/settings/account/widgets/profile_card.dart new file mode 100644 index 00000000..03e48a44 --- /dev/null +++ b/lib/screens/settings/account/widgets/profile_card.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:frosty/screens/settings/account/account_options.dart'; +import 'package:frosty/screens/settings/stores/auth_store.dart'; +import 'package:frosty/screens/settings/widgets/settings_tile_route.dart'; +import 'package:frosty/widgets/app_bar.dart'; +import 'package:frosty/widgets/button.dart'; +import 'package:frosty/widgets/dialog.dart'; +import 'package:frosty/widgets/list_tile.dart'; +import 'package:frosty/widgets/profile_picture.dart'; +import 'package:simple_icons/simple_icons.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +class ProfileCard extends StatelessWidget { + final AuthStore authStore; + + const ProfileCard({Key? key, required this.authStore}) : super(key: key); + + Future _showLoginDialog(BuildContext context) { + return showDialog( + context: context, + builder: (context) => FrostyDialog( + title: 'Log In', + content: Column( + children: [ + Button( + padding: const EdgeInsets.symmetric(horizontal: 30.0, vertical: 15.0), + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) { + return Scaffold( + appBar: const FrostyAppBar( + title: Text('Connect with Twitch'), + ), + body: WebView( + initialUrl: authStore.loginUri.toString(), + navigationDelegate: authStore.handleNavigation, + javascriptMode: JavascriptMode.unrestricted, + ), + ); + }, + ), + ), + icon: const Icon(SimpleIcons.twitch), + child: const Text( + 'Connect with Twitch', + style: TextStyle( + fontSize: 16, + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(height: 20.0), + const Center( + child: Text( + 'Or', + style: TextStyle(fontWeight: FontWeight.w600), + ), + ), + const SizedBox(height: 20.0), + TextField( + autocorrect: false, + textAlign: TextAlign.center, + decoration: const InputDecoration( + hintText: 'Token', + ), + onSubmitted: (token) { + authStore.login(token: token); + Navigator.pop(context); + }, + ) + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Observer( + builder: (context) { + if (authStore.error != null) { + return FrostyListTile( + leading: const Icon( + Icons.error_outline_rounded, + color: Colors.red, + ), + title: 'Failed to connect', + trailing: Button( + onPressed: authStore.init, + padding: const EdgeInsets.symmetric(horizontal: 15.0), + child: const Text('Reconnect'), + ), + ); + } + if (authStore.isLoggedIn && authStore.user.details != null) { + return SettingsTileRoute( + leading: ProfilePicture( + userLogin: authStore.user.details!.login, + radius: 12, + ), + title: authStore.user.details!.displayName, + child: AccountOptions(authStore: authStore), + ); + } + return FrostyListTile( + leading: const Icon(Icons.no_accounts_rounded), + title: 'Anonymous', + subtitle: const Text('Tap to log in and enable the ability to chat, view followed streams, and more.'), + trailing: const SizedBox( + height: double.infinity, + child: Icon(Icons.chevron_right_rounded), + ), + onTap: () => _showLoginDialog(context), + ); + }, + ); + } +} diff --git a/lib/screens/settings/account_settings.dart b/lib/screens/settings/account_settings.dart deleted file mode 100644 index cea6a80b..00000000 --- a/lib/screens/settings/account_settings.dart +++ /dev/null @@ -1,265 +0,0 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:frosty/constants.dart'; -import 'package:frosty/screens/settings/stores/auth_store.dart'; -import 'package:frosty/screens/settings/stores/settings_store.dart'; -import 'package:frosty/widgets/alert_message.dart'; -import 'package:frosty/widgets/block_button.dart'; -import 'package:frosty/widgets/button.dart'; -import 'package:frosty/widgets/dialog.dart'; -import 'package:webview_flutter/webview_flutter.dart'; - -class AccountSettings extends StatelessWidget { - final SettingsStore settingsStore; - final AuthStore authStore; - - const AccountSettings({ - Key? key, - required this.settingsStore, - required this.authStore, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Observer( - builder: (context) => ExpansionTile( - leading: const Icon(Icons.account_circle), - title: const Text( - 'Account', - style: TextStyle( - fontSize: 18.0, - fontWeight: FontWeight.bold, - ), - ), - children: [ - ProfileCard(authStore: authStore), - if (authStore.isLoggedIn) ...[ - ListTile( - title: const Text('Blocked users'), - trailing: Icon(Icons.adaptive.arrow_forward), - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => BlockedUsers( - authStore: authStore, - ), - ), - ), - ), - ], - ], - ), - ); - } -} - -class ProfileCard extends StatelessWidget { - final AuthStore authStore; - - const ProfileCard({Key? key, required this.authStore}) : super(key: key); - - Future _showLoginDialog(BuildContext context) { - return showDialog( - context: context, - builder: (context) => FrostyDialog( - title: 'Log In', - content: Column( - children: [ - Button( - padding: const EdgeInsets.symmetric(horizontal: 30.0, vertical: 15.0), - onPressed: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) { - return Scaffold( - appBar: AppBar( - title: const Text('Connect with Twitch'), - ), - body: WebView( - initialUrl: authStore.loginUri.toString(), - navigationDelegate: authStore.handleNavigation, - javascriptMode: JavascriptMode.unrestricted, - ), - ); - }, - ), - ), - icon: Image.asset( - 'assets/icons/TwitchGlitchWhite.png', - height: 25, - ), - child: const Text( - 'Connect with Twitch', - style: TextStyle( - fontSize: 16, - color: Colors.white, - fontWeight: FontWeight.w600, - ), - ), - ), - const SizedBox(height: 20.0), - const Center( - child: Text( - 'Or', - style: TextStyle(fontWeight: FontWeight.w600), - ), - ), - const SizedBox(height: 20.0), - TextField( - autocorrect: false, - textAlign: TextAlign.center, - decoration: const InputDecoration( - hintText: 'Token', - ), - onSubmitted: (token) { - authStore.login(token: token); - Navigator.pop(context); - }, - ) - ], - ), - ), - ); - } - - Future _showLogoutDialog(BuildContext context) { - return showDialog( - context: context, - builder: (context) => FrostyDialog( - title: 'Log Out', - content: const Text('Are you sure you want to log out?'), - actions: [ - Button( - onPressed: () { - authStore.logout(); - Navigator.pop(context); - }, - child: const Text('Yes'), - ), - Button( - fill: true, - onPressed: Navigator.of(context).pop, - color: Colors.red.shade700, - child: const Text('Cancel'), - ) - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - return Observer( - builder: (context) { - if (authStore.error != null) { - return ListTile( - leading: const Icon(Icons.error), - title: const Text('Failed to connect'), - trailing: Button( - onPressed: authStore.init, - padding: const EdgeInsets.symmetric(horizontal: 15.0), - child: const Text('Try Again'), - ), - ); - } - if (authStore.isLoggedIn && authStore.user.details != null) { - return ListTile( - leading: CircleAvatar( - foregroundImage: CachedNetworkImageProvider(authStore.user.details!.profileImageUrl), - ), - title: Text( - authStore.user.details!.displayName, - style: const TextStyle(fontWeight: FontWeight.w600), - ), - trailing: Button( - onPressed: () => _showLogoutDialog(context), - icon: const Icon(Icons.logout_outlined), - padding: const EdgeInsets.symmetric(horizontal: 15.0), - fill: true, - color: Colors.red.shade700, - child: const Text('Log Out'), - ), - ); - } - return ListTile( - isThreeLine: true, - leading: const Icon( - Icons.no_accounts, - size: 40, - ), - title: const Text('Anonymous User'), - subtitle: const Text('Log in to chat, view followed streams, and more.'), - trailing: Button( - color: Theme.of(context).colorScheme.secondary, - onPressed: () => _showLoginDialog(context), - fill: true, - padding: const EdgeInsets.symmetric(horizontal: 15.0), - icon: const Icon(Icons.login), - child: const Text('Log In'), - ), - ); - }, - ); - } -} - -class BlockedUsers extends StatelessWidget { - final AuthStore authStore; - - const BlockedUsers({ - Key? key, - required this.authStore, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Blocked Users'), - ), - body: RefreshIndicator( - onRefresh: () async { - HapticFeedback.lightImpact(); - - await authStore.user.refreshBlockedUsers(headers: authStore.headersTwitch); - }, - child: Observer( - builder: (context) { - if (authStore.user.blockedUsers.isEmpty) { - return const Center( - child: AlertMessage( - message: 'No blocked users', - ), - ); - } - return ListView( - children: authStore.user.blockedUsers.map( - (user) { - final displayName = regexEnglish.hasMatch(user.displayName) ? user.displayName : '${user.displayName} (${user.userLogin})'; - - return ListTile( - title: Tooltip( - preferBelow: false, - message: displayName, - child: Text( - displayName, - overflow: TextOverflow.ellipsis, - ), - ), - trailing: BlockButton( - authStore: authStore, - targetUser: displayName, - targetUserId: user.userId, - ), - ); - }, - ).toList(), - ); - }, - ), - ), - ); - } -} diff --git a/lib/screens/settings/chat_settings.dart b/lib/screens/settings/chat_settings.dart index 1a11c5b6..1ef94920 100644 --- a/lib/screens/settings/chat_settings.dart +++ b/lib/screens/settings/chat_settings.dart @@ -1,10 +1,13 @@ import 'dart:io'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/constants.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; +import 'package:frosty/screens/settings/widgets/settings_list_select.dart'; +import 'package:frosty/screens/settings/widgets/settings_list_slider.dart'; +import 'package:frosty/screens/settings/widgets/settings_list_switch.dart'; +import 'package:frosty/widgets/cached_image.dart'; import 'package:frosty/widgets/section_header.dart'; class ChatSettings extends StatefulWidget { @@ -25,231 +28,18 @@ class _ChatSettingsState extends State { const timestamps = ['Disabled', '12-hour', '24-hour']; - const sectionPadding = EdgeInsets.only(left: 16.0, bottom: 5.0, top: 30.0); - final settingsStore = widget.settingsStore; return Observer( - builder: (context) => ExpansionTile( - expandedCrossAxisAlignment: CrossAxisAlignment.start, - childrenPadding: const EdgeInsets.symmetric(vertical: 10.0), - leading: const Icon(Icons.chat), - title: const Text( - 'Chat', - style: TextStyle( - fontSize: 18.0, - fontWeight: FontWeight.bold, - ), - ), + builder: (context) => ListView( children: [ - ListTile( - title: Row( - children: [ - const Text('Message delay'), - const Spacer(), - Text('${settingsStore.chatDelay.toInt()} ${settingsStore.chatDelay == 1.0 ? 'second' : 'seconds'}'), - ], - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Slider.adaptive( - value: settingsStore.chatDelay, - min: 0.0, - max: 30.0, - divisions: 30, - onChanged: (newValue) => settingsStore.chatDelay = newValue, - ), - Text('Adds a delay before each message is rendered in chat. ${Platform.isIOS ? '15 seconds is recommended for iOS.' : ''}'), - const SizedBox(height: 15), - ], - ), - ), - SwitchListTile.adaptive( - isThreeLine: true, - title: const Text('Prevent sleep in chat-only mode'), - subtitle: const Text('Requires restarting the chat in order to take effect.'), - value: settingsStore.chatOnlyPreventSleep, - onChanged: !settingsStore.showVideo ? (newValue) => settingsStore.chatOnlyPreventSleep = newValue : null, - ), - SwitchListTile.adaptive( - isThreeLine: true, - title: const Text('Autocomplete'), - subtitle: const Text('Shows a bar that suggests matching emotes and mentions while typing.'), - value: settingsStore.autocomplete, - onChanged: settingsStore.showBottomBar ? (newValue) => settingsStore.autocomplete = newValue : null, - ), - const SectionHeader( - 'Layout', - padding: sectionPadding, - showDivider: true, - ), - SwitchListTile.adaptive( - title: const Text('Bottom bar'), - value: settingsStore.showBottomBar, - onChanged: (newValue) => settingsStore.showBottomBar = newValue, - ), - SwitchListTile.adaptive( - title: const Text('Emote menu button on left side'), - value: settingsStore.emoteMenuButtonOnLeft, - onChanged: (newValue) => settingsStore.emoteMenuButtonOnLeft = newValue, - ), - SwitchListTile.adaptive( - title: const Text('Landscape chat on left side'), - value: settingsStore.landscapeChatLeftSide, - onChanged: (newValue) => settingsStore.landscapeChatLeftSide = newValue, - ), - SwitchListTile.adaptive( - isThreeLine: true, - title: const Text('Force vertical chat in landscape mode'), - subtitle: const Text('Note: this option is intended for tablets and other larger displays.'), - value: settingsStore.landscapeForceVerticalChat, - onChanged: (newValue) => settingsStore.landscapeForceVerticalChat = newValue, - ), - SwitchListTile.adaptive( - isThreeLine: true, - title: const Text('Notifications on bottom'), - subtitle: const Text('Shows notifications (e.g., "Message copied") on the bottom of the chat.'), - value: settingsStore.chatNotificationsOnBottom, - onChanged: (newValue) => settingsStore.chatNotificationsOnBottom = newValue, - ), - ListTile( - isThreeLine: true, - title: const Text('Landscape fill cutout side'), - subtitle: const Text('Overrides and fills the available space in the display cutout/notch.'), - trailing: DropdownButton( - value: settingsStore.landscapeCutout, - onChanged: (LandscapeCutoutType? newValue) => settingsStore.landscapeCutout = newValue!, - items: LandscapeCutoutType.values - .map((LandscapeCutoutType value) => DropdownMenuItem( - value: value, - child: Text(landscapeCutouts[value.index]), - )) - .toList(), - ), - ), - const SizedBox(height: 15.0), - ListTile( - title: Row( - children: [ - const Text('Chat width'), - const Spacer(), - Text('${(settingsStore.chatWidth * 100).toStringAsFixed(0)}%'), - ], - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Slider.adaptive( - value: settingsStore.chatWidth, - min: 0.2, - max: 0.6, - divisions: 8, - onChanged: (newValue) => settingsStore.chatWidth = newValue, - ), - const Text('Sets the width of the chat in fullscreen and theater mode.'), - const SizedBox(height: 15), - ], - ), - ), - const SizedBox(height: 15.0), - ListTile( - title: Row( - children: [ - const Text('Chat overlay opacity'), - const Spacer(), - Text('${(settingsStore.fullScreenChatOverlayOpacity * 100).toStringAsFixed(0)}%'), - ], - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Slider.adaptive( - value: settingsStore.fullScreenChatOverlayOpacity, - min: 0.0, - max: 1.0, - divisions: 10, - onChanged: (newValue) => settingsStore.fullScreenChatOverlayOpacity = newValue, - ), - const Text('Sets the opacity (transparency) of the overlay chat in fullscreen mode.'), - const SizedBox(height: 15), - ], - ), - ), - const SectionHeader( - 'Message Appearance', - padding: sectionPadding, - showDivider: true, - ), - SwitchListTile.adaptive( - isThreeLine: true, - title: const Text('Readable name colors'), - subtitle: const Text('Adjusts the lightness value of overly bright or dark names.'), - value: settingsStore.useReadableColors, - onChanged: (newValue) => settingsStore.useReadableColors = newValue, - ), - SwitchListTile.adaptive( - isThreeLine: true, - title: const Text('Show deleted messages'), - subtitle: const Text('Restores the original message of deleted messages.'), - value: settingsStore.showDeletedMessages, - onChanged: (newValue) => settingsStore.showDeletedMessages = newValue, - ), - SwitchListTile.adaptive( - isThreeLine: true, - title: const Text('Message dividers'), - subtitle: const Text('Shows a subtle divider between each message.'), - value: settingsStore.showChatMessageDividers, - onChanged: (newValue) => settingsStore.showChatMessageDividers = newValue, - ), - ListTile( - isThreeLine: true, - title: const Text('Message timestamps'), - subtitle: const Text('Shows timestamps for when a message was sent.'), - trailing: DropdownButton( - value: settingsStore.timestampType, - onChanged: (TimestampType? newTimestamp) => settingsStore.timestampType = newTimestamp!, - items: TimestampType.values - .map((TimestampType value) => DropdownMenuItem( - value: value, - child: Text(timestamps[value.index]), - )) - .toList(), - ), - ), - const SectionHeader( - 'Alerts', - padding: sectionPadding, - showDivider: true, - ), - SwitchListTile.adaptive( - title: const Text('Highlight first time chatters'), - value: settingsStore.highlightFirstTimeChatter, - onChanged: (newValue) => settingsStore.highlightFirstTimeChatter = newValue, - ), - SwitchListTile.adaptive( - isThreeLine: true, - title: const Text('Show notices'), - subtitle: const Text('Shows notices such as subs and re-subs, announcements, and raids.'), - value: settingsStore.showUserNotices, - onChanged: (newValue) => settingsStore.showUserNotices = newValue, - ), - const SectionHeader( - 'Message Sizing', - padding: sectionPadding, - showDivider: true, - ), + const SectionHeader('Message sizing'), ExpansionTile( - title: const Text('Preview'), + title: const Text('Preview', style: TextStyle(fontWeight: FontWeight.w500)), children: [ Container( width: double.infinity, - padding: const EdgeInsets.all(20.0), - margin: const EdgeInsets.all(10.0), - decoration: BoxDecoration( - border: Border.all(color: Colors.deepPurple), - borderRadius: BorderRadius.circular(15.0), - ), + padding: const EdgeInsets.all(16), child: DefaultTextStyle( style: DefaultTextStyle.of(context).style.copyWith(fontSize: settingsStore.fontSize), child: Column( @@ -260,8 +50,9 @@ class _ChatSettingsState extends State { children: [ WidgetSpan( alignment: PlaceholderAlignment.middle, - child: CachedNetworkImage( - imageUrl: 'https://static-cdn.jtvnw.net/badges/v1/bbbe0db0-a598-423e-86d0-f9fb98ca1933/3', + child: FrostyCachedNetworkImage( + imageUrl: + 'https://static-cdn.jtvnw.net/badges/v1/bbbe0db0-a598-423e-86d0-f9fb98ca1933/3', height: defaultBadgeSize * settingsStore.badgeScale, width: defaultBadgeSize * settingsStore.badgeScale, ), @@ -269,7 +60,7 @@ class _ChatSettingsState extends State { const TextSpan(text: ' Badge and emote preview. '), WidgetSpan( alignment: PlaceholderAlignment.middle, - child: CachedNetworkImage( + child: FrostyCachedNetworkImage( imageUrl: 'https://static-cdn.jtvnw.net/emoticons/v2/425618/default/dark/3.0', height: defaultEmoteSize * settingsStore.emoteScale, width: defaultEmoteSize * settingsStore.emoteScale, @@ -295,86 +86,164 @@ class _ChatSettingsState extends State { ), ], ), - const SizedBox(height: 10.0), - ListTile( - title: Row( - children: [ - const Text('Badge scale'), - const Spacer(), - Text('${settingsStore.badgeScale.toStringAsFixed(2)}x'), - ], - ), - subtitle: Slider.adaptive( - value: settingsStore.badgeScale, - min: 0.25, - max: 3.0, - divisions: 11, - onChanged: (newValue) => settingsStore.badgeScale = newValue, - ), + SettingsListSlider( + title: 'Badge scale', + trailing: '${settingsStore.badgeScale.toStringAsFixed(2)}x', + value: settingsStore.badgeScale, + min: 0.25, + max: 3.0, + divisions: 11, + onChanged: (newValue) => settingsStore.badgeScale = newValue, ), - ListTile( - title: Row( - children: [ - const Text('Emote scale'), - const Spacer(), - Text('${settingsStore.emoteScale.toStringAsFixed(2)}x'), - ], - ), - subtitle: Slider.adaptive( - value: settingsStore.emoteScale, - min: 0.25, - max: 3.0, - divisions: 11, - onChanged: (newValue) => settingsStore.emoteScale = newValue, - ), + SettingsListSlider( + title: 'Emote scale', + trailing: '${settingsStore.emoteScale.toStringAsFixed(2)}x', + value: settingsStore.emoteScale, + min: 0.25, + max: 3.0, + divisions: 11, + onChanged: (newValue) => settingsStore.emoteScale = newValue, ), - ListTile( - title: Row( - children: [ - const Text('Message scale'), - const Spacer(), - Text('${settingsStore.messageScale.toStringAsFixed(2)}x'), - ], - ), - subtitle: Slider.adaptive( - value: settingsStore.messageScale, - min: 0.5, - max: 2.0, - divisions: 6, - onChanged: (newValue) => settingsStore.messageScale = newValue, - ), + SettingsListSlider( + title: 'Message scale', + trailing: '${settingsStore.messageScale.toStringAsFixed(2)}x', + value: settingsStore.messageScale, + min: 0.5, + max: 2.0, + divisions: 6, + onChanged: (newValue) => settingsStore.messageScale = newValue, ), - ListTile( - title: Row( - children: [ - const Text('Message spacing'), - const Spacer(), - Text(settingsStore.messageSpacing.toStringAsFixed(0).toString()), - ], - ), - subtitle: Slider.adaptive( - value: settingsStore.messageSpacing, - min: 0.0, - max: 30.0, - divisions: 6, - onChanged: (newValue) => settingsStore.messageSpacing = newValue, - ), + SettingsListSlider( + title: 'Message spacing', + trailing: '${settingsStore.messageSpacing.toStringAsFixed(0)}px', + value: settingsStore.messageSpacing, + max: 30.0, + divisions: 6, + onChanged: (newValue) => settingsStore.messageSpacing = newValue, ), - ListTile( - title: Row( - children: [ - const Text('Font size'), - const Spacer(), - Text(settingsStore.fontSize.toInt().toString()), - ], - ), - subtitle: Slider.adaptive( - value: settingsStore.fontSize, - min: 5, - max: 20, - divisions: 15, - onChanged: (newValue) => settingsStore.fontSize = newValue, - ), + SettingsListSlider( + title: 'Font size', + trailing: settingsStore.fontSize.toInt().toString(), + value: settingsStore.fontSize, + min: 5, + max: 20, + divisions: 15, + onChanged: (newValue) => settingsStore.fontSize = newValue, + ), + const SectionHeader('Message appearance'), + SettingsListSwitch( + title: 'Use readable name colors', + subtitle: const Text('Adjusts the lightness value of overly bright and dark names.'), + value: settingsStore.useReadableColors, + onChanged: (newValue) => settingsStore.useReadableColors = newValue, + ), + SettingsListSwitch( + title: 'Show deleted messages', + subtitle: const Text('Restores the original message of deleted messages.'), + value: settingsStore.showDeletedMessages, + onChanged: (newValue) => settingsStore.showDeletedMessages = newValue, + ), + SettingsListSwitch( + title: 'Show message dividers', + value: settingsStore.showChatMessageDividers, + onChanged: (newValue) => settingsStore.showChatMessageDividers = newValue, + ), + SettingsListSelect( + title: 'Message timestamps', + selectedOption: timestamps[settingsStore.timestampType.index], + options: timestamps, + onChanged: (newValue) => settingsStore.timestampType = TimestampType.values[timestamps.indexOf(newValue)], + ), + const SectionHeader('Delay'), + SettingsListSlider( + title: 'Message delay', + trailing: '${settingsStore.chatDelay.toInt()}s', + subtitle: + 'Adds a delay (in seconds) before each message is rendered in chat. ${Platform.isIOS ? '15 seconds is recommended for iOS.' : ''}', + value: settingsStore.chatDelay, + max: 30.0, + divisions: 30, + onChanged: (newValue) => settingsStore.chatDelay = newValue, + ), + const SectionHeader('Alerts'), + SettingsListSwitch( + title: 'Highlight first time chatters', + value: settingsStore.highlightFirstTimeChatter, + onChanged: (newValue) => settingsStore.highlightFirstTimeChatter = newValue, + ), + SettingsListSwitch( + title: 'Show notices', + subtitle: const Text('Shows notices such as subs and re-subs, announcements, and raids.'), + value: settingsStore.showUserNotices, + onChanged: (newValue) => settingsStore.showUserNotices = newValue, + ), + const SectionHeader('Layout'), + SettingsListSwitch( + title: 'Show bottom bar', + value: settingsStore.showBottomBar, + onChanged: (newValue) => settingsStore.showBottomBar = newValue, + ), + SettingsListSwitch( + title: 'Move emote menu button left', + subtitle: const Text('Places the emote menu button on the left side to avoid accidental presses.'), + value: settingsStore.emoteMenuButtonOnLeft, + onChanged: (newValue) => settingsStore.emoteMenuButtonOnLeft = newValue, + ), + SettingsListSwitch( + title: 'Move notifications to bottom', + value: settingsStore.chatNotificationsOnBottom, + onChanged: (newValue) => settingsStore.chatNotificationsOnBottom = newValue, + ), + const SectionHeader('Landscape mode'), + SettingsListSwitch( + title: 'Move chat left', + value: settingsStore.landscapeChatLeftSide, + onChanged: (newValue) => settingsStore.landscapeChatLeftSide = newValue, + ), + SettingsListSwitch( + title: 'Force vertical chat', + subtitle: const Text('Intended for tablets and other larger displays.'), + value: settingsStore.landscapeForceVerticalChat, + onChanged: (newValue) => settingsStore.landscapeForceVerticalChat = newValue, + ), + SettingsListSelect( + title: 'Fill notch side', + subtitle: 'Overrides and fills the available space in devices with a display notch.', + selectedOption: landscapeCutouts[settingsStore.landscapeCutout.index], + options: landscapeCutouts, + onChanged: (newValue) => + settingsStore.landscapeCutout = LandscapeCutoutType.values[landscapeCutouts.indexOf(newValue)], + ), + SettingsListSlider( + title: 'Chat width', + trailing: '${(settingsStore.chatWidth * 100).toStringAsFixed(0)}%', + value: settingsStore.chatWidth, + min: 0.2, + max: 0.6, + divisions: 8, + onChanged: (newValue) => settingsStore.chatWidth = newValue, + ), + SettingsListSlider( + title: 'Chat overlay opacity', + trailing: '${(settingsStore.fullScreenChatOverlayOpacity * 100).toStringAsFixed(0)}%', + subtitle: 'Sets the opacity (transparency) of the overlay chat in fullscreen mode.', + value: settingsStore.fullScreenChatOverlayOpacity, + divisions: 10, + onChanged: (newValue) => settingsStore.fullScreenChatOverlayOpacity = newValue, + ), + const SectionHeader('Sleep'), + SettingsListSwitch( + title: 'Prevent sleep in chat-only mode', + subtitle: const Text('Requires restarting the chat in order to take effect.'), + value: settingsStore.chatOnlyPreventSleep, + onChanged: (newValue) => settingsStore.chatOnlyPreventSleep = newValue, + ), + const SectionHeader('Autocomplete'), + SettingsListSwitch( + title: 'Show autocomplete bar', + subtitle: const Text('Shows a bar containing matching emotes and mentions while typing.'), + value: settingsStore.autocomplete, + onChanged: (newValue) => settingsStore.autocomplete = newValue, ), ], ), diff --git a/lib/screens/settings/general_settings.dart b/lib/screens/settings/general_settings.dart index 1e160376..5ec57d17 100644 --- a/lib/screens/settings/general_settings.dart +++ b/lib/screens/settings/general_settings.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; +import 'package:frosty/screens/settings/widgets/settings_list_select.dart'; +import 'package:frosty/screens/settings/widgets/settings_list_switch.dart'; +import 'package:frosty/widgets/section_header.dart'; class GeneralSettings extends StatelessWidget { final SettingsStore settingsStore; @@ -12,50 +15,29 @@ class GeneralSettings extends StatelessWidget { const themes = ['System', 'Light', 'Dark', 'Black']; return Observer( - builder: (context) => ExpansionTile( - leading: const Icon(Icons.settings), - title: const Text( - 'General', - style: TextStyle( - fontSize: 18.0, - fontWeight: FontWeight.bold, - ), - ), + builder: (context) => ListView( children: [ - ListTile( - title: const Text('Theme'), - trailing: DropdownButton( - value: settingsStore.themeType, - onChanged: (ThemeType? newTheme) => settingsStore.themeType = newTheme!, - items: ThemeType.values - .map((ThemeType value) => DropdownMenuItem( - value: value, - child: Text(themes[value.index]), - )) - .toList(), - ), + const SectionHeader('Display'), + SettingsListSelect( + title: 'Theme', + selectedOption: themes[settingsStore.themeType.index], + options: themes, + onChanged: (newTheme) => settingsStore.themeType = ThemeType.values[themes.indexOf(newTheme)], ), - SwitchListTile.adaptive( - title: const Text('Large stream card'), + const SectionHeader('Stream card'), + SettingsListSwitch( + title: 'Use large stream card', value: settingsStore.largeStreamCard, onChanged: (newValue) => settingsStore.largeStreamCard = newValue, ), - SwitchListTile.adaptive( - title: const Text('Stream card thumbnails'), + SettingsListSwitch( + title: 'Show thumbnail', value: settingsStore.showThumbnails, onChanged: (newValue) => settingsStore.showThumbnails = newValue, ), - SwitchListTile.adaptive( - isThreeLine: true, - title: const Text('Stream uptime on thumbnails'), - subtitle: const Text('Shows the uptime of the stream in the HH:MM:SS format.'), - value: settingsStore.showThumbnailUptime, - onChanged: settingsStore.showThumbnails ? (newValue) => settingsStore.showThumbnailUptime = newValue : null, - ), - SwitchListTile.adaptive( - isThreeLine: true, - title: const Text('Launch URLs in external browser'), - subtitle: const Text('Opens links in the default external browser.'), + const SectionHeader('Links'), + SettingsListSwitch( + title: 'Open links in external browser', value: settingsStore.launchUrlExternal, onChanged: (newValue) => settingsStore.launchUrlExternal = newValue, ), diff --git a/lib/screens/settings/other_settings.dart b/lib/screens/settings/other_settings.dart index b6e2bbb6..a86a74dc 100644 --- a/lib/screens/settings/other_settings.dart +++ b/lib/screens/settings/other_settings.dart @@ -5,9 +5,11 @@ import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:frosty/constants.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; +import 'package:frosty/screens/settings/widgets/settings_list_switch.dart'; import 'package:frosty/widgets/alert_message.dart'; import 'package:frosty/widgets/button.dart'; import 'package:frosty/widgets/dialog.dart'; +import 'package:frosty/widgets/list_tile.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -30,7 +32,7 @@ class _OtherSettingsState extends State { context: context, builder: (context) => FrostyDialog( title: 'Reset All Settings', - content: const Text('Are you sure you want to reset all settings?'), + message: 'Are you sure you want to reset all settings?', actions: [ Button( onPressed: () { @@ -50,9 +52,8 @@ class _OtherSettingsState extends State { child: const Text('Yes'), ), Button( - fill: true, onPressed: Navigator.of(context).pop, - color: Colors.red.shade700, + color: Colors.grey, child: const Text('Cancel'), ), ], @@ -62,20 +63,11 @@ class _OtherSettingsState extends State { @override Widget build(BuildContext context) { - return ExpansionTile( - childrenPadding: const EdgeInsets.only(bottom: 10.0), - leading: const Icon(Icons.help), - title: const Text( - 'Other', - style: TextStyle( - fontSize: 18.0, - fontWeight: FontWeight.bold, - ), - ), + return Column( children: [ - ListTile( - leading: const Icon(Icons.info), - title: const Text('About Frosty'), + FrostyListTile( + leading: const Icon(Icons.info_outline_rounded), + title: 'About Frosty', onTap: () async { final packageInfo = await PackageInfo.fromPlatform(); @@ -91,23 +83,44 @@ class _OtherSettingsState extends State { ); }, ), - ListTile( - leading: const Icon(Icons.launch), - title: const Text('Changelog'), + FrostyListTile( + leading: const Icon(Icons.launch_rounded), + title: 'FAQ', + onTap: () => launchUrl(Uri.parse('https://www.frostyapp.io/#faq'), + mode: widget.settingsStore.launchUrlExternal ? LaunchMode.externalApplication : LaunchMode.inAppWebView), + ), + FrostyListTile( + leading: const Icon(Icons.launch_rounded), + title: 'Changelog', onTap: () => launchUrl(Uri.parse('https://github.com/tommyxchow/frosty/releases'), mode: widget.settingsStore.launchUrlExternal ? LaunchMode.externalApplication : LaunchMode.inAppWebView), ), - ListTile( - leading: const Icon(Icons.launch), - title: const Text('FAQ'), - onTap: () => launchUrl(Uri.parse('https://github.com/tommyxchow/frosty#faq'), - mode: widget.settingsStore.launchUrlExternal ? LaunchMode.externalApplication : LaunchMode.inAppWebView), + FrostyListTile( + leading: const Icon(Icons.delete_outline_rounded), + title: 'Clear image cache', + onTap: () async { + HapticFeedback.mediumImpact(); + + await DefaultCacheManager().emptyCache(); + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: AlertMessage(message: 'Image cache cleared'), + behavior: SnackBarBehavior.floating, + ), + ); + }, + ), + FrostyListTile( + leading: const Icon(Icons.restore_rounded), + title: 'Reset settings', + onTap: () => _showConfirmDialog(context), ), Observer( - builder: (_) => SwitchListTile.adaptive( - title: const Text('Send anonymous crash logs'), - isThreeLine: true, - subtitle: const Text('Help improve Frosty by sending anonymous crash logs through Sentry.'), + builder: (_) => SettingsListSwitch( + title: 'Send anonymous crash logs', + subtitle: const Text('Help improve Frosty by sending anonymous crash logs through Sentry.io.'), value: widget.settingsStore.sendCrashLogs, onChanged: (newValue) { if (newValue == true) { @@ -119,41 +132,6 @@ class _OtherSettingsState extends State { }, ), ), - ...[ - Button( - padding: const EdgeInsets.symmetric(horizontal: 15.0, vertical: 10.0), - icon: const Icon(Icons.delete_sweep), - child: const Text('Clear image cache'), - onPressed: () async { - HapticFeedback.mediumImpact(); - - await DefaultCacheManager().emptyCache(); - - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: AlertMessage(message: 'Image cache cleared'), - behavior: SnackBarBehavior.floating, - ), - ); - }, - ), - Button( - padding: const EdgeInsets.symmetric(horizontal: 15.0, vertical: 10.0), - onPressed: () => _showConfirmDialog(context), - icon: const Icon(Icons.restore), - child: const Text('Reset all settings'), - ) - ].map( - (button) => Container( - padding: const EdgeInsets.symmetric( - horizontal: 15.0, - vertical: 5.0, - ), - width: double.infinity, - child: button, - ), - ), ], ); } diff --git a/lib/screens/settings/settings.dart b/lib/screens/settings/settings.dart index 5868e9bb..dfc5fb9d 100644 --- a/lib/screens/settings/settings.dart +++ b/lib/screens/settings/settings.dart @@ -1,15 +1,18 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:frosty/screens/settings/account_settings.dart'; +import 'package:frosty/screens/settings/account/widgets/profile_card.dart'; import 'package:frosty/screens/settings/chat_settings.dart'; import 'package:frosty/screens/settings/general_settings.dart'; import 'package:frosty/screens/settings/other_settings.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/screens/settings/video_settings.dart'; +import 'package:frosty/screens/settings/widgets/settings_tile_route.dart'; +import 'package:frosty/widgets/app_bar.dart'; +import 'package:frosty/widgets/section_header.dart'; import 'package:provider/provider.dart'; +import 'package:simple_icons/simple_icons.dart'; import 'package:url_launcher/url_launcher.dart'; class Settings extends StatelessWidget { @@ -20,21 +23,21 @@ class Settings extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( + appBar: FrostyAppBar( title: const Text('Settings'), actions: [ if (Platform.isAndroid) IconButton( - tooltip: 'Support the app', + tooltip: 'Support Frosty', onPressed: () => launchUrl(Uri.parse('https://www.buymeacoffee.com/tommychow'), mode: settingsStore.launchUrlExternal ? LaunchMode.externalApplication : LaunchMode.inAppWebView), - icon: const FaIcon(FontAwesomeIcons.circleDollarToSlot), + icon: const Icon(SimpleIcons.buymeacoffee), ), IconButton( tooltip: 'View source on GitHub', onPressed: () => launchUrl(Uri.parse('https://github.com/tommyxchow/frosty'), mode: settingsStore.launchUrlExternal ? LaunchMode.externalApplication : LaunchMode.inAppWebView), - icon: const FaIcon(FontAwesomeIcons.github), + icon: const Icon(SimpleIcons.github), ), ], ), @@ -42,15 +45,26 @@ class Settings extends StatelessWidget { bottom: false, child: ListView( children: [ - const SizedBox(height: 10.0), - AccountSettings( - settingsStore: settingsStore, - authStore: context.read(), + const SectionHeader('Account'), + ProfileCard(authStore: context.read()), + const SectionHeader('Customize'), + SettingsTileRoute( + leading: const Icon(Icons.settings_outlined), + title: 'General', + child: GeneralSettings(settingsStore: settingsStore), ), - GeneralSettings(settingsStore: settingsStore), - VideoSettings(settingsStore: settingsStore), - ChatSettings(settingsStore: settingsStore), - OtherSettings(settingsStore: settingsStore), + SettingsTileRoute( + leading: const Icon(Icons.tv_rounded), + title: 'Video', + child: VideoSettings(settingsStore: settingsStore), + ), + SettingsTileRoute( + leading: const Icon(Icons.chat_outlined), + title: 'Chat', + child: ChatSettings(settingsStore: settingsStore), + ), + const SectionHeader('Other'), + OtherSettings(settingsStore: settingsStore) ], ), ), diff --git a/lib/screens/settings/stores/auth_store.dart b/lib/screens/settings/stores/auth_store.dart index bb43d2d9..d270555a 100644 --- a/lib/screens/settings/stores/auth_store.dart +++ b/lib/screens/settings/stores/auth_store.dart @@ -1,11 +1,13 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/constants.dart'; import 'package:frosty/main.dart'; import 'package:frosty/screens/settings/stores/user_store.dart'; +import 'package:frosty/widgets/button.dart'; +import 'package:frosty/widgets/dialog.dart'; import 'package:mobx/mobx.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:webview_flutter/webview_flutter.dart'; @@ -88,6 +90,52 @@ abstract class AuthBase with Store { return NavigationDecision.navigate; } + /// Shows a dialog verifying that the user is sure they want to block/unblock the target user. + Future showBlockDialog( + BuildContext context, { + required String targetUser, + required String targetUserId, + }) { + final isBlocked = user.blockedUsers.where((blockedUser) => blockedUser.userId == targetUserId).isNotEmpty; + + final title = isBlocked ? 'Unblock $targetUser' : 'Block $targetUser'; + + final message = + 'Are you sure you want to ${isBlocked ? 'unblock $targetUser?' : 'block $targetUser? This will remove them from channel lists, search results, and chat messages.'}'; + + void onPressed() { + if (isBlocked) { + user.unblock(targetId: targetUserId, headers: headersTwitch); + } else { + user.block( + targetId: targetUserId, + displayName: targetUser, + headers: headersTwitch, + ); + } + Navigator.pop(context); + } + + return showDialog( + context: context, + builder: (context) => FrostyDialog( + title: title, + message: message, + actions: [ + Button( + onPressed: onPressed, + child: const Text('Yes'), + ), + Button( + onPressed: Navigator.of(context).pop, + color: Colors.grey, + child: const Text('Cancel'), + ), + ], + ), + ); + } + AuthBase({required this.twitchApi}) : user = UserStore(twitchApi: twitchApi); /// Initialize by retrieving a token if it does not already exist. diff --git a/lib/screens/settings/stores/settings_store.dart b/lib/screens/settings/stores/settings_store.dart index 5e66c2f1..f6b28dfb 100644 --- a/lib/screens/settings/stores/settings_store.dart +++ b/lib/screens/settings/stores/settings_store.dart @@ -13,16 +13,22 @@ class SettingsStore extends _SettingsStoreBase with _$SettingsStore { abstract class _SettingsStoreBase with Store { // * General Settings + // Theme defaults static const defaultThemeType = ThemeType.system; + + // Stream card defaults static const defaultShowThumbnails = true; static const defaultLargeStreamCard = false; - static const defaultShowThumbnailUptime = false; + + // Links defaults static const defaultLaunchUrlExternal = false; + // Theme options @JsonKey(defaultValue: defaultThemeType, unknownEnumValue: ThemeType.system) @observable var themeType = defaultThemeType; + // Stream card options @JsonKey(defaultValue: defaultShowThumbnails) @observable var showThumbnails = defaultShowThumbnails; @@ -31,10 +37,7 @@ abstract class _SettingsStoreBase with Store { @observable var largeStreamCard = defaultLargeStreamCard; - @JsonKey(defaultValue: defaultShowThumbnailUptime) - @observable - var showThumbnailUptime = defaultShowThumbnailUptime; - + // Links options @JsonKey(defaultValue: defaultLaunchUrlExternal) @observable var launchUrlExternal = defaultLaunchUrlExternal; @@ -42,22 +45,28 @@ abstract class _SettingsStoreBase with Store { @action void resetGeneralSettings() { themeType = defaultThemeType; - showThumbnails = defaultShowThumbnails; + largeStreamCard = defaultLargeStreamCard; - showThumbnailUptime = defaultShowThumbnailUptime; + showThumbnails = defaultShowThumbnails; + launchUrlExternal = defaultLaunchUrlExternal; } // * Video Settings + // Player defaults static const defaultShowVideo = true; + + // Overlay defaults static const defaultShowOverlay = true; static const defaultToggleableOverlay = false; - static const defaultOverlayOpacity = 0.8; + static const defaultOverlayOpacity = 0.5; + // Player options @JsonKey(defaultValue: defaultShowVideo) @observable var showVideo = defaultShowVideo; + // Overlay options @JsonKey(defaultValue: defaultShowOverlay) @observable var showOverlay = defaultShowOverlay; @@ -73,107 +82,52 @@ abstract class _SettingsStoreBase with Store { @action void resetVideoSettings() { showVideo = defaultShowVideo; + showOverlay = defaultShowOverlay; toggleableOverlay = defaultToggleableOverlay; overlayOpacity = defaultOverlayOpacity; } // * Chat Settings - static const defaultChatDelay = 0.0; - static const defaultChatOnlyPreventSleep = true; - static const defaultAutocomplete = true; - static const defaultLandscapeCutout = LandscapeCutoutType.none; - - static const defaultShowBottomBar = true; - static const defaultEmoteMenuButtonOnLeft = false; - static const defaultLandscapeChatLeftSide = false; - static const defaultLandscapeForceVerticalChat = false; - static const defaultChatNotificationsOnBottom = false; - static const defaultChatWidth = 0.3; - static const defaultFullScreenChatOverlayOpacity = 0.5; - - static const defaultUseReadableColors = true; - static const defaultShowDeletedMessages = false; - static const defaultShowChatMessageDividers = false; - static const defaultTimestampType = TimestampType.disabled; - - static const defaultHighlightFirstTimeChatter = true; - static const defaultShowUserNotices = true; - + // Message sizing defaults static const defaultBadgeScale = 1.0; static const defaultEmoteScale = 1.0; static const defaultMessageScale = 1.0; static const defaultMessageSpacing = 10.0; static const defaultFontSize = 12.0; - @JsonKey(defaultValue: defaultChatDelay) - @observable - var chatDelay = defaultChatDelay; + // Message appearance defaults + static const defaultUseReadableColors = true; + static const defaultShowDeletedMessages = false; + static const defaultShowChatMessageDividers = false; + static const defaultTimestampType = TimestampType.disabled; - @JsonKey(defaultValue: defaultChatOnlyPreventSleep) - @observable - var chatOnlyPreventSleep = defaultChatOnlyPreventSleep; + // Delay defaults + static const defaultChatDelay = 0.0; - @JsonKey(defaultValue: defaultAutocomplete) - @observable - var autocomplete = defaultAutocomplete; + // Alert defaults + static const defaultHighlightFirstTimeChatter = true; + static const defaultShowUserNotices = true; - @JsonKey(defaultValue: defaultShowBottomBar) - @observable - var showBottomBar = defaultShowBottomBar; + // Layout defaults + static const defaultShowBottomBar = true; + static const defaultEmoteMenuButtonOnLeft = false; + static const defaultChatNotificationsOnBottom = false; - @JsonKey(defaultValue: defaultEmoteMenuButtonOnLeft) - @observable - var emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft; + // Landscape mode defaults + static const defaultLandscapeChatLeftSide = false; + static const defaultLandscapeForceVerticalChat = false; + static const defaultLandscapeCutout = LandscapeCutoutType.none; + static const defaultChatWidth = 0.3; + static const defaultFullScreenChatOverlayOpacity = 0.5; - @JsonKey(defaultValue: defaultLandscapeChatLeftSide) - @observable - var landscapeChatLeftSide = defaultLandscapeChatLeftSide; + // Sleep defaults + static const defaultChatOnlyPreventSleep = false; - @JsonKey(defaultValue: defaultLandscapeForceVerticalChat) - @observable - var landscapeForceVerticalChat = defaultLandscapeForceVerticalChat; - - @JsonKey(defaultValue: defaultChatNotificationsOnBottom) - @observable - var chatNotificationsOnBottom = defaultChatNotificationsOnBottom; - - @JsonKey(defaultValue: defaultLandscapeCutout) - @observable - var landscapeCutout = defaultLandscapeCutout; - - @JsonKey(defaultValue: defaultChatWidth) - @observable - var chatWidth = defaultChatWidth; - - @JsonKey(defaultValue: defaultFullScreenChatOverlayOpacity) - @observable - var fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity; - - @JsonKey(defaultValue: defaultUseReadableColors) - @observable - var useReadableColors = defaultUseReadableColors; - - @JsonKey(defaultValue: defaultShowDeletedMessages) - @observable - var showDeletedMessages = defaultShowDeletedMessages; - - @JsonKey(defaultValue: defaultShowChatMessageDividers) - @observable - var showChatMessageDividers = defaultShowChatMessageDividers; - - @JsonKey(defaultValue: defaultTimestampType, unknownEnumValue: TimestampType.disabled) - @observable - var timestampType = defaultTimestampType; - - @JsonKey(defaultValue: defaultHighlightFirstTimeChatter) - @observable - var highlightFirstTimeChatter = defaultHighlightFirstTimeChatter; - - @JsonKey(defaultValue: defaultShowUserNotices) - @observable - var showUserNotices = defaultHighlightFirstTimeChatter; + // Autocomplete defaults + static const defaultAutocomplete = true; + // Message sizing options @JsonKey(defaultValue: defaultBadgeScale) @observable var badgeScale = defaultBadgeScale; @@ -194,30 +148,111 @@ abstract class _SettingsStoreBase with Store { @observable var fontSize = defaultFontSize; + // Message appearance options + @JsonKey(defaultValue: defaultUseReadableColors) + @observable + var useReadableColors = defaultUseReadableColors; + + @JsonKey(defaultValue: defaultShowDeletedMessages) + @observable + var showDeletedMessages = defaultShowDeletedMessages; + + @JsonKey(defaultValue: defaultShowChatMessageDividers) + @observable + var showChatMessageDividers = defaultShowChatMessageDividers; + + @JsonKey(defaultValue: defaultTimestampType, unknownEnumValue: TimestampType.disabled) + @observable + var timestampType = defaultTimestampType; + + // Delay options + @JsonKey(defaultValue: defaultChatDelay) + @observable + var chatDelay = defaultChatDelay; + + // Alert options + @JsonKey(defaultValue: defaultHighlightFirstTimeChatter) + @observable + var highlightFirstTimeChatter = defaultHighlightFirstTimeChatter; + + @JsonKey(defaultValue: defaultShowUserNotices) + @observable + var showUserNotices = defaultHighlightFirstTimeChatter; + + // Layout options + @JsonKey(defaultValue: defaultShowBottomBar) + @observable + var showBottomBar = defaultShowBottomBar; + + @JsonKey(defaultValue: defaultEmoteMenuButtonOnLeft) + @observable + var emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft; + + @JsonKey(defaultValue: defaultChatNotificationsOnBottom) + @observable + var chatNotificationsOnBottom = defaultChatNotificationsOnBottom; + + // Landscape mode options + @JsonKey(defaultValue: defaultLandscapeChatLeftSide) + @observable + var landscapeChatLeftSide = defaultLandscapeChatLeftSide; + + @JsonKey(defaultValue: defaultLandscapeForceVerticalChat) + @observable + var landscapeForceVerticalChat = defaultLandscapeForceVerticalChat; + + @JsonKey(defaultValue: defaultLandscapeCutout) + @observable + var landscapeCutout = defaultLandscapeCutout; + + @JsonKey(defaultValue: defaultChatWidth) + @observable + var chatWidth = defaultChatWidth; + + @JsonKey(defaultValue: defaultFullScreenChatOverlayOpacity) + @observable + var fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity; + + // Sleep options + @JsonKey(defaultValue: defaultChatOnlyPreventSleep) + @observable + var chatOnlyPreventSleep = defaultChatOnlyPreventSleep; + + // Autocomplete options + @JsonKey(defaultValue: defaultAutocomplete) + @observable + var autocomplete = defaultAutocomplete; + @action void resetChatSettings() { - chatDelay = defaultChatDelay; - chatOnlyPreventSleep = defaultChatOnlyPreventSleep; - autocomplete = defaultAutocomplete; - showBottomBar = defaultShowBottomBar; - emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft; - landscapeChatLeftSide = defaultLandscapeChatLeftSide; - landscapeForceVerticalChat = defaultLandscapeForceVerticalChat; - chatNotificationsOnBottom = defaultChatNotificationsOnBottom; - landscapeCutout = defaultLandscapeCutout; - chatWidth = defaultChatWidth; - fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity; - useReadableColors = defaultUseReadableColors; - showDeletedMessages = defaultShowDeletedMessages; - showChatMessageDividers = defaultShowChatMessageDividers; - timestampType = defaultTimestampType; - highlightFirstTimeChatter = defaultHighlightFirstTimeChatter; - showUserNotices = defaultShowUserNotices; badgeScale = defaultBadgeScale; emoteScale = defaultEmoteScale; messageScale = defaultMessageScale; messageSpacing = defaultMessageSpacing; fontSize = defaultFontSize; + + useReadableColors = defaultUseReadableColors; + showDeletedMessages = defaultShowDeletedMessages; + showChatMessageDividers = defaultShowChatMessageDividers; + timestampType = defaultTimestampType; + + chatDelay = defaultChatDelay; + + highlightFirstTimeChatter = defaultHighlightFirstTimeChatter; + showUserNotices = defaultShowUserNotices; + + showBottomBar = defaultShowBottomBar; + emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft; + chatNotificationsOnBottom = defaultChatNotificationsOnBottom; + + landscapeChatLeftSide = defaultLandscapeChatLeftSide; + landscapeForceVerticalChat = defaultLandscapeForceVerticalChat; + landscapeCutout = defaultLandscapeCutout; + chatWidth = defaultChatWidth; + fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity; + + chatOnlyPreventSleep = defaultChatOnlyPreventSleep; + autocomplete = defaultAutocomplete; } // * Other settings @@ -234,17 +269,12 @@ abstract class _SettingsStoreBase with Store { // * Global configs static const defaultFullScreen = false; - static const defaultExpandInfo = true; static const defaultFullScreenChatOverlay = false; @JsonKey(defaultValue: defaultFullScreen) @observable var fullScreen = defaultFullScreen; - @JsonKey(defaultValue: defaultExpandInfo) - @observable - var expandInfo = defaultExpandInfo; - @JsonKey(defaultValue: defaultFullScreenChatOverlay) @observable var fullScreenChatOverlay = defaultFullScreenChatOverlay; @@ -252,7 +282,6 @@ abstract class _SettingsStoreBase with Store { @action void resetGlobalConfigs() { fullScreen = defaultFullScreen; - expandInfo = defaultExpandInfo; fullScreenChatOverlay = defaultFullScreenChatOverlay; } diff --git a/lib/screens/settings/stores/settings_store.g.dart b/lib/screens/settings/stores/settings_store.g.dart index d1f652f4..c197d246 100644 --- a/lib/screens/settings/stores/settings_store.g.dart +++ b/lib/screens/settings/stores/settings_store.g.dart @@ -13,28 +13,16 @@ SettingsStore _$SettingsStoreFromJson(Map json) => ThemeType.system ..showThumbnails = json['showThumbnails'] as bool? ?? true ..largeStreamCard = json['largeStreamCard'] as bool? ?? false - ..showThumbnailUptime = json['showThumbnailUptime'] as bool? ?? false ..launchUrlExternal = json['launchUrlExternal'] as bool? ?? false ..showVideo = json['showVideo'] as bool? ?? true ..showOverlay = json['showOverlay'] as bool? ?? true ..toggleableOverlay = json['toggleableOverlay'] as bool? ?? false - ..overlayOpacity = (json['overlayOpacity'] as num?)?.toDouble() ?? 0.8 - ..chatDelay = (json['chatDelay'] as num?)?.toDouble() ?? 0.0 - ..chatOnlyPreventSleep = json['chatOnlyPreventSleep'] as bool? ?? true - ..autocomplete = json['autocomplete'] as bool? ?? true - ..showBottomBar = json['showBottomBar'] as bool? ?? true - ..emoteMenuButtonOnLeft = json['emoteMenuButtonOnLeft'] as bool? ?? false - ..landscapeChatLeftSide = json['landscapeChatLeftSide'] as bool? ?? false - ..landscapeForceVerticalChat = - json['landscapeForceVerticalChat'] as bool? ?? false - ..chatNotificationsOnBottom = - json['chatNotificationsOnBottom'] as bool? ?? false - ..landscapeCutout = $enumDecodeNullable( - _$LandscapeCutoutTypeEnumMap, json['landscapeCutout']) ?? - LandscapeCutoutType.none - ..chatWidth = (json['chatWidth'] as num?)?.toDouble() ?? 0.3 - ..fullScreenChatOverlayOpacity = - (json['fullScreenChatOverlayOpacity'] as num?)?.toDouble() ?? 0.5 + ..overlayOpacity = (json['overlayOpacity'] as num?)?.toDouble() ?? 0.5 + ..badgeScale = (json['badgeScale'] as num?)?.toDouble() ?? 1.0 + ..emoteScale = (json['emoteScale'] as num?)?.toDouble() ?? 1.0 + ..messageScale = (json['messageScale'] as num?)?.toDouble() ?? 1.0 + ..messageSpacing = (json['messageSpacing'] as num?)?.toDouble() ?? 10.0 + ..fontSize = (json['fontSize'] as num?)?.toDouble() ?? 12.0 ..useReadableColors = json['useReadableColors'] as bool? ?? true ..showDeletedMessages = json['showDeletedMessages'] as bool? ?? false ..showChatMessageDividers = @@ -43,17 +31,27 @@ SettingsStore _$SettingsStoreFromJson(Map json) => _$TimestampTypeEnumMap, json['timestampType'], unknownValue: TimestampType.disabled) ?? TimestampType.disabled + ..chatDelay = (json['chatDelay'] as num?)?.toDouble() ?? 0.0 ..highlightFirstTimeChatter = json['highlightFirstTimeChatter'] as bool? ?? true ..showUserNotices = json['showUserNotices'] as bool? ?? true - ..badgeScale = (json['badgeScale'] as num?)?.toDouble() ?? 1.0 - ..emoteScale = (json['emoteScale'] as num?)?.toDouble() ?? 1.0 - ..messageScale = (json['messageScale'] as num?)?.toDouble() ?? 1.0 - ..messageSpacing = (json['messageSpacing'] as num?)?.toDouble() ?? 10.0 - ..fontSize = (json['fontSize'] as num?)?.toDouble() ?? 12.0 + ..showBottomBar = json['showBottomBar'] as bool? ?? true + ..emoteMenuButtonOnLeft = json['emoteMenuButtonOnLeft'] as bool? ?? false + ..chatNotificationsOnBottom = + json['chatNotificationsOnBottom'] as bool? ?? false + ..landscapeChatLeftSide = json['landscapeChatLeftSide'] as bool? ?? false + ..landscapeForceVerticalChat = + json['landscapeForceVerticalChat'] as bool? ?? false + ..landscapeCutout = $enumDecodeNullable( + _$LandscapeCutoutTypeEnumMap, json['landscapeCutout']) ?? + LandscapeCutoutType.none + ..chatWidth = (json['chatWidth'] as num?)?.toDouble() ?? 0.3 + ..fullScreenChatOverlayOpacity = + (json['fullScreenChatOverlayOpacity'] as num?)?.toDouble() ?? 0.5 + ..chatOnlyPreventSleep = json['chatOnlyPreventSleep'] as bool? ?? false + ..autocomplete = json['autocomplete'] as bool? ?? true ..sendCrashLogs = json['sendCrashLogs'] as bool? ?? true ..fullScreen = json['fullScreen'] as bool? ?? false - ..expandInfo = json['expandInfo'] as bool? ?? true ..fullScreenChatOverlay = json['fullScreenChatOverlay'] as bool? ?? false; Map _$SettingsStoreToJson(SettingsStore instance) => @@ -61,38 +59,36 @@ Map _$SettingsStoreToJson(SettingsStore instance) => 'themeType': _$ThemeTypeEnumMap[instance.themeType]!, 'showThumbnails': instance.showThumbnails, 'largeStreamCard': instance.largeStreamCard, - 'showThumbnailUptime': instance.showThumbnailUptime, 'launchUrlExternal': instance.launchUrlExternal, 'showVideo': instance.showVideo, 'showOverlay': instance.showOverlay, 'toggleableOverlay': instance.toggleableOverlay, 'overlayOpacity': instance.overlayOpacity, - 'chatDelay': instance.chatDelay, - 'chatOnlyPreventSleep': instance.chatOnlyPreventSleep, - 'autocomplete': instance.autocomplete, - 'showBottomBar': instance.showBottomBar, - 'emoteMenuButtonOnLeft': instance.emoteMenuButtonOnLeft, - 'landscapeChatLeftSide': instance.landscapeChatLeftSide, - 'landscapeForceVerticalChat': instance.landscapeForceVerticalChat, - 'chatNotificationsOnBottom': instance.chatNotificationsOnBottom, - 'landscapeCutout': - _$LandscapeCutoutTypeEnumMap[instance.landscapeCutout]!, - 'chatWidth': instance.chatWidth, - 'fullScreenChatOverlayOpacity': instance.fullScreenChatOverlayOpacity, - 'useReadableColors': instance.useReadableColors, - 'showDeletedMessages': instance.showDeletedMessages, - 'showChatMessageDividers': instance.showChatMessageDividers, - 'timestampType': _$TimestampTypeEnumMap[instance.timestampType]!, - 'highlightFirstTimeChatter': instance.highlightFirstTimeChatter, - 'showUserNotices': instance.showUserNotices, 'badgeScale': instance.badgeScale, 'emoteScale': instance.emoteScale, 'messageScale': instance.messageScale, 'messageSpacing': instance.messageSpacing, 'fontSize': instance.fontSize, + 'useReadableColors': instance.useReadableColors, + 'showDeletedMessages': instance.showDeletedMessages, + 'showChatMessageDividers': instance.showChatMessageDividers, + 'timestampType': _$TimestampTypeEnumMap[instance.timestampType]!, + 'chatDelay': instance.chatDelay, + 'highlightFirstTimeChatter': instance.highlightFirstTimeChatter, + 'showUserNotices': instance.showUserNotices, + 'showBottomBar': instance.showBottomBar, + 'emoteMenuButtonOnLeft': instance.emoteMenuButtonOnLeft, + 'chatNotificationsOnBottom': instance.chatNotificationsOnBottom, + 'landscapeChatLeftSide': instance.landscapeChatLeftSide, + 'landscapeForceVerticalChat': instance.landscapeForceVerticalChat, + 'landscapeCutout': + _$LandscapeCutoutTypeEnumMap[instance.landscapeCutout]!, + 'chatWidth': instance.chatWidth, + 'fullScreenChatOverlayOpacity': instance.fullScreenChatOverlayOpacity, + 'chatOnlyPreventSleep': instance.chatOnlyPreventSleep, + 'autocomplete': instance.autocomplete, 'sendCrashLogs': instance.sendCrashLogs, 'fullScreen': instance.fullScreen, - 'expandInfo': instance.expandInfo, 'fullScreenChatOverlay': instance.fullScreenChatOverlay, }; @@ -103,6 +99,12 @@ const _$ThemeTypeEnumMap = { ThemeType.black: 'black', }; +const _$TimestampTypeEnumMap = { + TimestampType.disabled: 'disabled', + TimestampType.twelve: 'twelve', + TimestampType.twentyFour: 'twentyFour', +}; + const _$LandscapeCutoutTypeEnumMap = { LandscapeCutoutType.none: 'none', LandscapeCutoutType.left: 'left', @@ -110,12 +112,6 @@ const _$LandscapeCutoutTypeEnumMap = { LandscapeCutoutType.both: 'both', }; -const _$TimestampTypeEnumMap = { - TimestampType.disabled: 'disabled', - TimestampType.twelve: 'twelve', - TimestampType.twentyFour: 'twentyFour', -}; - // ************************************************************************** // StoreGenerator // ************************************************************************** @@ -171,22 +167,6 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } - late final _$showThumbnailUptimeAtom = - Atom(name: '_SettingsStoreBase.showThumbnailUptime', context: context); - - @override - bool get showThumbnailUptime { - _$showThumbnailUptimeAtom.reportRead(); - return super.showThumbnailUptime; - } - - @override - set showThumbnailUptime(bool value) { - _$showThumbnailUptimeAtom.reportWrite(value, super.showThumbnailUptime, () { - super.showThumbnailUptime = value; - }); - } - late final _$launchUrlExternalAtom = Atom(name: '_SettingsStoreBase.launchUrlExternal', context: context); @@ -267,287 +247,6 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } - late final _$chatDelayAtom = - Atom(name: '_SettingsStoreBase.chatDelay', context: context); - - @override - double get chatDelay { - _$chatDelayAtom.reportRead(); - return super.chatDelay; - } - - @override - set chatDelay(double value) { - _$chatDelayAtom.reportWrite(value, super.chatDelay, () { - super.chatDelay = value; - }); - } - - late final _$chatOnlyPreventSleepAtom = - Atom(name: '_SettingsStoreBase.chatOnlyPreventSleep', context: context); - - @override - bool get chatOnlyPreventSleep { - _$chatOnlyPreventSleepAtom.reportRead(); - return super.chatOnlyPreventSleep; - } - - @override - set chatOnlyPreventSleep(bool value) { - _$chatOnlyPreventSleepAtom.reportWrite(value, super.chatOnlyPreventSleep, - () { - super.chatOnlyPreventSleep = value; - }); - } - - late final _$autocompleteAtom = - Atom(name: '_SettingsStoreBase.autocomplete', context: context); - - @override - bool get autocomplete { - _$autocompleteAtom.reportRead(); - return super.autocomplete; - } - - @override - set autocomplete(bool value) { - _$autocompleteAtom.reportWrite(value, super.autocomplete, () { - super.autocomplete = value; - }); - } - - late final _$showBottomBarAtom = - Atom(name: '_SettingsStoreBase.showBottomBar', context: context); - - @override - bool get showBottomBar { - _$showBottomBarAtom.reportRead(); - return super.showBottomBar; - } - - @override - set showBottomBar(bool value) { - _$showBottomBarAtom.reportWrite(value, super.showBottomBar, () { - super.showBottomBar = value; - }); - } - - late final _$emoteMenuButtonOnLeftAtom = - Atom(name: '_SettingsStoreBase.emoteMenuButtonOnLeft', context: context); - - @override - bool get emoteMenuButtonOnLeft { - _$emoteMenuButtonOnLeftAtom.reportRead(); - return super.emoteMenuButtonOnLeft; - } - - @override - set emoteMenuButtonOnLeft(bool value) { - _$emoteMenuButtonOnLeftAtom.reportWrite(value, super.emoteMenuButtonOnLeft, - () { - super.emoteMenuButtonOnLeft = value; - }); - } - - late final _$landscapeChatLeftSideAtom = - Atom(name: '_SettingsStoreBase.landscapeChatLeftSide', context: context); - - @override - bool get landscapeChatLeftSide { - _$landscapeChatLeftSideAtom.reportRead(); - return super.landscapeChatLeftSide; - } - - @override - set landscapeChatLeftSide(bool value) { - _$landscapeChatLeftSideAtom.reportWrite(value, super.landscapeChatLeftSide, - () { - super.landscapeChatLeftSide = value; - }); - } - - late final _$landscapeForceVerticalChatAtom = Atom( - name: '_SettingsStoreBase.landscapeForceVerticalChat', context: context); - - @override - bool get landscapeForceVerticalChat { - _$landscapeForceVerticalChatAtom.reportRead(); - return super.landscapeForceVerticalChat; - } - - @override - set landscapeForceVerticalChat(bool value) { - _$landscapeForceVerticalChatAtom - .reportWrite(value, super.landscapeForceVerticalChat, () { - super.landscapeForceVerticalChat = value; - }); - } - - late final _$chatNotificationsOnBottomAtom = Atom( - name: '_SettingsStoreBase.chatNotificationsOnBottom', context: context); - - @override - bool get chatNotificationsOnBottom { - _$chatNotificationsOnBottomAtom.reportRead(); - return super.chatNotificationsOnBottom; - } - - @override - set chatNotificationsOnBottom(bool value) { - _$chatNotificationsOnBottomAtom - .reportWrite(value, super.chatNotificationsOnBottom, () { - super.chatNotificationsOnBottom = value; - }); - } - - late final _$landscapeCutoutAtom = - Atom(name: '_SettingsStoreBase.landscapeCutout', context: context); - - @override - LandscapeCutoutType get landscapeCutout { - _$landscapeCutoutAtom.reportRead(); - return super.landscapeCutout; - } - - @override - set landscapeCutout(LandscapeCutoutType value) { - _$landscapeCutoutAtom.reportWrite(value, super.landscapeCutout, () { - super.landscapeCutout = value; - }); - } - - late final _$chatWidthAtom = - Atom(name: '_SettingsStoreBase.chatWidth', context: context); - - @override - double get chatWidth { - _$chatWidthAtom.reportRead(); - return super.chatWidth; - } - - @override - set chatWidth(double value) { - _$chatWidthAtom.reportWrite(value, super.chatWidth, () { - super.chatWidth = value; - }); - } - - late final _$fullScreenChatOverlayOpacityAtom = Atom( - name: '_SettingsStoreBase.fullScreenChatOverlayOpacity', - context: context); - - @override - double get fullScreenChatOverlayOpacity { - _$fullScreenChatOverlayOpacityAtom.reportRead(); - return super.fullScreenChatOverlayOpacity; - } - - @override - set fullScreenChatOverlayOpacity(double value) { - _$fullScreenChatOverlayOpacityAtom - .reportWrite(value, super.fullScreenChatOverlayOpacity, () { - super.fullScreenChatOverlayOpacity = value; - }); - } - - late final _$useReadableColorsAtom = - Atom(name: '_SettingsStoreBase.useReadableColors', context: context); - - @override - bool get useReadableColors { - _$useReadableColorsAtom.reportRead(); - return super.useReadableColors; - } - - @override - set useReadableColors(bool value) { - _$useReadableColorsAtom.reportWrite(value, super.useReadableColors, () { - super.useReadableColors = value; - }); - } - - late final _$showDeletedMessagesAtom = - Atom(name: '_SettingsStoreBase.showDeletedMessages', context: context); - - @override - bool get showDeletedMessages { - _$showDeletedMessagesAtom.reportRead(); - return super.showDeletedMessages; - } - - @override - set showDeletedMessages(bool value) { - _$showDeletedMessagesAtom.reportWrite(value, super.showDeletedMessages, () { - super.showDeletedMessages = value; - }); - } - - late final _$showChatMessageDividersAtom = Atom( - name: '_SettingsStoreBase.showChatMessageDividers', context: context); - - @override - bool get showChatMessageDividers { - _$showChatMessageDividersAtom.reportRead(); - return super.showChatMessageDividers; - } - - @override - set showChatMessageDividers(bool value) { - _$showChatMessageDividersAtom - .reportWrite(value, super.showChatMessageDividers, () { - super.showChatMessageDividers = value; - }); - } - - late final _$timestampTypeAtom = - Atom(name: '_SettingsStoreBase.timestampType', context: context); - - @override - TimestampType get timestampType { - _$timestampTypeAtom.reportRead(); - return super.timestampType; - } - - @override - set timestampType(TimestampType value) { - _$timestampTypeAtom.reportWrite(value, super.timestampType, () { - super.timestampType = value; - }); - } - - late final _$highlightFirstTimeChatterAtom = Atom( - name: '_SettingsStoreBase.highlightFirstTimeChatter', context: context); - - @override - bool get highlightFirstTimeChatter { - _$highlightFirstTimeChatterAtom.reportRead(); - return super.highlightFirstTimeChatter; - } - - @override - set highlightFirstTimeChatter(bool value) { - _$highlightFirstTimeChatterAtom - .reportWrite(value, super.highlightFirstTimeChatter, () { - super.highlightFirstTimeChatter = value; - }); - } - - late final _$showUserNoticesAtom = - Atom(name: '_SettingsStoreBase.showUserNotices', context: context); - - @override - bool get showUserNotices { - _$showUserNoticesAtom.reportRead(); - return super.showUserNotices; - } - - @override - set showUserNotices(bool value) { - _$showUserNoticesAtom.reportWrite(value, super.showUserNotices, () { - super.showUserNotices = value; - }); - } - late final _$badgeScaleAtom = Atom(name: '_SettingsStoreBase.badgeScale', context: context); @@ -628,6 +327,287 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } + late final _$useReadableColorsAtom = + Atom(name: '_SettingsStoreBase.useReadableColors', context: context); + + @override + bool get useReadableColors { + _$useReadableColorsAtom.reportRead(); + return super.useReadableColors; + } + + @override + set useReadableColors(bool value) { + _$useReadableColorsAtom.reportWrite(value, super.useReadableColors, () { + super.useReadableColors = value; + }); + } + + late final _$showDeletedMessagesAtom = + Atom(name: '_SettingsStoreBase.showDeletedMessages', context: context); + + @override + bool get showDeletedMessages { + _$showDeletedMessagesAtom.reportRead(); + return super.showDeletedMessages; + } + + @override + set showDeletedMessages(bool value) { + _$showDeletedMessagesAtom.reportWrite(value, super.showDeletedMessages, () { + super.showDeletedMessages = value; + }); + } + + late final _$showChatMessageDividersAtom = Atom( + name: '_SettingsStoreBase.showChatMessageDividers', context: context); + + @override + bool get showChatMessageDividers { + _$showChatMessageDividersAtom.reportRead(); + return super.showChatMessageDividers; + } + + @override + set showChatMessageDividers(bool value) { + _$showChatMessageDividersAtom + .reportWrite(value, super.showChatMessageDividers, () { + super.showChatMessageDividers = value; + }); + } + + late final _$timestampTypeAtom = + Atom(name: '_SettingsStoreBase.timestampType', context: context); + + @override + TimestampType get timestampType { + _$timestampTypeAtom.reportRead(); + return super.timestampType; + } + + @override + set timestampType(TimestampType value) { + _$timestampTypeAtom.reportWrite(value, super.timestampType, () { + super.timestampType = value; + }); + } + + late final _$chatDelayAtom = + Atom(name: '_SettingsStoreBase.chatDelay', context: context); + + @override + double get chatDelay { + _$chatDelayAtom.reportRead(); + return super.chatDelay; + } + + @override + set chatDelay(double value) { + _$chatDelayAtom.reportWrite(value, super.chatDelay, () { + super.chatDelay = value; + }); + } + + late final _$highlightFirstTimeChatterAtom = Atom( + name: '_SettingsStoreBase.highlightFirstTimeChatter', context: context); + + @override + bool get highlightFirstTimeChatter { + _$highlightFirstTimeChatterAtom.reportRead(); + return super.highlightFirstTimeChatter; + } + + @override + set highlightFirstTimeChatter(bool value) { + _$highlightFirstTimeChatterAtom + .reportWrite(value, super.highlightFirstTimeChatter, () { + super.highlightFirstTimeChatter = value; + }); + } + + late final _$showUserNoticesAtom = + Atom(name: '_SettingsStoreBase.showUserNotices', context: context); + + @override + bool get showUserNotices { + _$showUserNoticesAtom.reportRead(); + return super.showUserNotices; + } + + @override + set showUserNotices(bool value) { + _$showUserNoticesAtom.reportWrite(value, super.showUserNotices, () { + super.showUserNotices = value; + }); + } + + late final _$showBottomBarAtom = + Atom(name: '_SettingsStoreBase.showBottomBar', context: context); + + @override + bool get showBottomBar { + _$showBottomBarAtom.reportRead(); + return super.showBottomBar; + } + + @override + set showBottomBar(bool value) { + _$showBottomBarAtom.reportWrite(value, super.showBottomBar, () { + super.showBottomBar = value; + }); + } + + late final _$emoteMenuButtonOnLeftAtom = + Atom(name: '_SettingsStoreBase.emoteMenuButtonOnLeft', context: context); + + @override + bool get emoteMenuButtonOnLeft { + _$emoteMenuButtonOnLeftAtom.reportRead(); + return super.emoteMenuButtonOnLeft; + } + + @override + set emoteMenuButtonOnLeft(bool value) { + _$emoteMenuButtonOnLeftAtom.reportWrite(value, super.emoteMenuButtonOnLeft, + () { + super.emoteMenuButtonOnLeft = value; + }); + } + + late final _$chatNotificationsOnBottomAtom = Atom( + name: '_SettingsStoreBase.chatNotificationsOnBottom', context: context); + + @override + bool get chatNotificationsOnBottom { + _$chatNotificationsOnBottomAtom.reportRead(); + return super.chatNotificationsOnBottom; + } + + @override + set chatNotificationsOnBottom(bool value) { + _$chatNotificationsOnBottomAtom + .reportWrite(value, super.chatNotificationsOnBottom, () { + super.chatNotificationsOnBottom = value; + }); + } + + late final _$landscapeChatLeftSideAtom = + Atom(name: '_SettingsStoreBase.landscapeChatLeftSide', context: context); + + @override + bool get landscapeChatLeftSide { + _$landscapeChatLeftSideAtom.reportRead(); + return super.landscapeChatLeftSide; + } + + @override + set landscapeChatLeftSide(bool value) { + _$landscapeChatLeftSideAtom.reportWrite(value, super.landscapeChatLeftSide, + () { + super.landscapeChatLeftSide = value; + }); + } + + late final _$landscapeForceVerticalChatAtom = Atom( + name: '_SettingsStoreBase.landscapeForceVerticalChat', context: context); + + @override + bool get landscapeForceVerticalChat { + _$landscapeForceVerticalChatAtom.reportRead(); + return super.landscapeForceVerticalChat; + } + + @override + set landscapeForceVerticalChat(bool value) { + _$landscapeForceVerticalChatAtom + .reportWrite(value, super.landscapeForceVerticalChat, () { + super.landscapeForceVerticalChat = value; + }); + } + + late final _$landscapeCutoutAtom = + Atom(name: '_SettingsStoreBase.landscapeCutout', context: context); + + @override + LandscapeCutoutType get landscapeCutout { + _$landscapeCutoutAtom.reportRead(); + return super.landscapeCutout; + } + + @override + set landscapeCutout(LandscapeCutoutType value) { + _$landscapeCutoutAtom.reportWrite(value, super.landscapeCutout, () { + super.landscapeCutout = value; + }); + } + + late final _$chatWidthAtom = + Atom(name: '_SettingsStoreBase.chatWidth', context: context); + + @override + double get chatWidth { + _$chatWidthAtom.reportRead(); + return super.chatWidth; + } + + @override + set chatWidth(double value) { + _$chatWidthAtom.reportWrite(value, super.chatWidth, () { + super.chatWidth = value; + }); + } + + late final _$fullScreenChatOverlayOpacityAtom = Atom( + name: '_SettingsStoreBase.fullScreenChatOverlayOpacity', + context: context); + + @override + double get fullScreenChatOverlayOpacity { + _$fullScreenChatOverlayOpacityAtom.reportRead(); + return super.fullScreenChatOverlayOpacity; + } + + @override + set fullScreenChatOverlayOpacity(double value) { + _$fullScreenChatOverlayOpacityAtom + .reportWrite(value, super.fullScreenChatOverlayOpacity, () { + super.fullScreenChatOverlayOpacity = value; + }); + } + + late final _$chatOnlyPreventSleepAtom = + Atom(name: '_SettingsStoreBase.chatOnlyPreventSleep', context: context); + + @override + bool get chatOnlyPreventSleep { + _$chatOnlyPreventSleepAtom.reportRead(); + return super.chatOnlyPreventSleep; + } + + @override + set chatOnlyPreventSleep(bool value) { + _$chatOnlyPreventSleepAtom.reportWrite(value, super.chatOnlyPreventSleep, + () { + super.chatOnlyPreventSleep = value; + }); + } + + late final _$autocompleteAtom = + Atom(name: '_SettingsStoreBase.autocomplete', context: context); + + @override + bool get autocomplete { + _$autocompleteAtom.reportRead(); + return super.autocomplete; + } + + @override + set autocomplete(bool value) { + _$autocompleteAtom.reportWrite(value, super.autocomplete, () { + super.autocomplete = value; + }); + } + late final _$sendCrashLogsAtom = Atom(name: '_SettingsStoreBase.sendCrashLogs', context: context); @@ -660,22 +640,6 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } - late final _$expandInfoAtom = - Atom(name: '_SettingsStoreBase.expandInfo', context: context); - - @override - bool get expandInfo { - _$expandInfoAtom.reportRead(); - return super.expandInfo; - } - - @override - set expandInfo(bool value) { - _$expandInfoAtom.reportWrite(value, super.expandInfo, () { - super.expandInfo = value; - }); - } - late final _$fullScreenChatOverlayAtom = Atom(name: '_SettingsStoreBase.fullScreenChatOverlay', context: context); @@ -768,37 +732,35 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { themeType: ${themeType}, showThumbnails: ${showThumbnails}, largeStreamCard: ${largeStreamCard}, -showThumbnailUptime: ${showThumbnailUptime}, launchUrlExternal: ${launchUrlExternal}, showVideo: ${showVideo}, showOverlay: ${showOverlay}, toggleableOverlay: ${toggleableOverlay}, overlayOpacity: ${overlayOpacity}, -chatDelay: ${chatDelay}, -chatOnlyPreventSleep: ${chatOnlyPreventSleep}, -autocomplete: ${autocomplete}, -showBottomBar: ${showBottomBar}, -emoteMenuButtonOnLeft: ${emoteMenuButtonOnLeft}, -landscapeChatLeftSide: ${landscapeChatLeftSide}, -landscapeForceVerticalChat: ${landscapeForceVerticalChat}, -chatNotificationsOnBottom: ${chatNotificationsOnBottom}, -landscapeCutout: ${landscapeCutout}, -chatWidth: ${chatWidth}, -fullScreenChatOverlayOpacity: ${fullScreenChatOverlayOpacity}, -useReadableColors: ${useReadableColors}, -showDeletedMessages: ${showDeletedMessages}, -showChatMessageDividers: ${showChatMessageDividers}, -timestampType: ${timestampType}, -highlightFirstTimeChatter: ${highlightFirstTimeChatter}, -showUserNotices: ${showUserNotices}, badgeScale: ${badgeScale}, emoteScale: ${emoteScale}, messageScale: ${messageScale}, messageSpacing: ${messageSpacing}, fontSize: ${fontSize}, +useReadableColors: ${useReadableColors}, +showDeletedMessages: ${showDeletedMessages}, +showChatMessageDividers: ${showChatMessageDividers}, +timestampType: ${timestampType}, +chatDelay: ${chatDelay}, +highlightFirstTimeChatter: ${highlightFirstTimeChatter}, +showUserNotices: ${showUserNotices}, +showBottomBar: ${showBottomBar}, +emoteMenuButtonOnLeft: ${emoteMenuButtonOnLeft}, +chatNotificationsOnBottom: ${chatNotificationsOnBottom}, +landscapeChatLeftSide: ${landscapeChatLeftSide}, +landscapeForceVerticalChat: ${landscapeForceVerticalChat}, +landscapeCutout: ${landscapeCutout}, +chatWidth: ${chatWidth}, +fullScreenChatOverlayOpacity: ${fullScreenChatOverlayOpacity}, +chatOnlyPreventSleep: ${chatOnlyPreventSleep}, +autocomplete: ${autocomplete}, sendCrashLogs: ${sendCrashLogs}, fullScreen: ${fullScreen}, -expandInfo: ${expandInfo}, fullScreenChatOverlay: ${fullScreenChatOverlay} '''; } diff --git a/lib/screens/settings/video_settings.dart b/lib/screens/settings/video_settings.dart index 3333f99b..a2f1f292 100644 --- a/lib/screens/settings/video_settings.dart +++ b/lib/screens/settings/video_settings.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; +import 'package:frosty/screens/settings/widgets/settings_list_slider.dart'; +import 'package:frosty/screens/settings/widgets/settings_list_switch.dart'; +import 'package:frosty/widgets/section_header.dart'; class VideoSettings extends StatelessWidget { final SettingsStore settingsStore; @@ -10,58 +13,34 @@ class VideoSettings extends StatelessWidget { @override Widget build(BuildContext context) { return Observer( - builder: (context) => ExpansionTile( - leading: const Icon(Icons.ondemand_video), - title: const Text( - 'Video', - style: TextStyle( - fontSize: 18.0, - fontWeight: FontWeight.bold, - ), - ), + builder: (context) => ListView( children: [ - SwitchListTile.adaptive( - title: const Text('Video'), + const SectionHeader('Player'), + SettingsListSwitch( + title: 'Enable video', value: settingsStore.showVideo, onChanged: (newValue) => settingsStore.showVideo = newValue, ), - SwitchListTile.adaptive( - isThreeLine: true, - title: const Text('Custom overlay'), - subtitle: const Text('Replaces Twitch\'s default overlay with a mobile-friendly version.'), + const SectionHeader('Overlay'), + SettingsListSwitch( + title: 'Use custom video overlay', + subtitle: const Text('Replaces Twitch\'s default web overlay with a mobile-friendly version.'), value: settingsStore.showOverlay, - onChanged: settingsStore.showVideo ? (newValue) => settingsStore.showOverlay = newValue : null, + onChanged: (newValue) => settingsStore.showOverlay = newValue, ), - SwitchListTile.adaptive( - isThreeLine: true, - title: const Text('Long-press player to toggle overlay'), - subtitle: const Text('Allows switching between Twitch\'s default overlay and the custom overlay.'), + SettingsListSwitch( + title: 'Long-press player to toggle overlay', + subtitle: const Text('Allows switching between Twitch\'s overlay and the custom overlay.'), value: settingsStore.toggleableOverlay, - onChanged: settingsStore.showVideo ? (newValue) => settingsStore.toggleableOverlay = newValue : null, + onChanged: (newValue) => settingsStore.toggleableOverlay = newValue, ), - const SizedBox(height: 15.0), - ListTile( - title: Row( - children: [ - const Text('Custom overlay opacity'), - const Spacer(), - Text('${(settingsStore.overlayOpacity * 100).toStringAsFixed(0)}%'), - ], - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Slider.adaptive( - value: settingsStore.overlayOpacity, - min: 0.0, - max: 1.0, - divisions: 10, - onChanged: (newValue) => settingsStore.overlayOpacity = newValue, - ), - const Text('Adjusts the opacity (transparency) of the stream overlay when active.'), - const SizedBox(height: 15), - ], - ), + SettingsListSlider( + title: 'Custom overlay opacity', + trailing: '${(settingsStore.overlayOpacity * 100).toStringAsFixed(0)}%', + subtitle: 'Adjusts the opacity (transparency) of the custom video overlay when active.', + value: settingsStore.overlayOpacity, + divisions: 10, + onChanged: (newValue) => settingsStore.overlayOpacity = newValue, ), ], ), diff --git a/lib/screens/settings/widgets/settings_list_select.dart b/lib/screens/settings/widgets/settings_list_select.dart new file mode 100644 index 00000000..e8164646 --- /dev/null +++ b/lib/screens/settings/widgets/settings_list_select.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:frosty/widgets/bottom_sheet.dart'; + +/// A custom-styled adaptive [ListTile] with options to select. +class SettingsListSelect extends StatelessWidget { + final String title; + final String? subtitle; + final String selectedOption; + final List options; + final ValueChanged onChanged; + + const SettingsListSelect({ + Key? key, + required this.title, + this.subtitle, + required this.selectedOption, + required this.options, + required this.onChanged, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return ListTile( + isThreeLine: subtitle != null, + title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), + subtitle: subtitle != null ? Text(subtitle!) : null, + trailing: SizedBox( + height: double.infinity, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Opacity( + opacity: 0.8, + child: Text( + selectedOption, + ), + ), + const Icon(Icons.chevron_right_rounded), + ], + ), + ), + onTap: () => showModalBottomSheet( + backgroundColor: Colors.transparent, + isScrollControlled: true, + context: context, + builder: (context) => FrostyBottomSheet( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + 'Select ${title.toLowerCase()}', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18), + ), + ), + ...options + .map((option) => ListTile( + title: Text(option), + trailing: selectedOption == option ? const Icon(Icons.check_rounded) : null, + onTap: () { + onChanged(option); + Navigator.of(context).pop(); + })) + .toList() + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/settings/widgets/settings_list_slider.dart b/lib/screens/settings/widgets/settings_list_slider.dart new file mode 100644 index 00000000..6d265384 --- /dev/null +++ b/lib/screens/settings/widgets/settings_list_slider.dart @@ -0,0 +1,56 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +/// A custom-styled adaptive [Slider]. +class SettingsListSlider extends StatelessWidget { + final String title; + final String trailing; + final String? subtitle; + final double value; + final ValueChanged? onChanged; + final double min; + final double max; + final int? divisions; + + const SettingsListSlider({ + Key? key, + required this.title, + required this.trailing, + this.subtitle, + required this.value, + this.onChanged, + this.min = 0.0, + this.max = 1.0, + this.divisions, + }) : super(key: key); + + static const _textStyle = TextStyle(fontWeight: FontWeight.w500); + + @override + Widget build(BuildContext context) { + return ListTile( + contentPadding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), + title: Row( + children: [ + Text(title, style: _textStyle), + const Spacer(), + Text(trailing, style: _textStyle.copyWith(fontFeatures: [const FontFeature.tabularFigures()])), + ], + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Slider.adaptive( + value: value, + min: min, + max: max, + divisions: divisions, + onChanged: onChanged, + ), + if (subtitle != null) Text(subtitle!), + ], + ), + ); + } +} diff --git a/lib/screens/settings/widgets/settings_list_switch.dart b/lib/screens/settings/widgets/settings_list_switch.dart new file mode 100644 index 00000000..053ed989 --- /dev/null +++ b/lib/screens/settings/widgets/settings_list_switch.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +/// A custom-styled adaptive [SwitchListTile]. +class SettingsListSwitch extends StatelessWidget { + final String? title; + final Widget? subtitle; + final bool value; + final ValueChanged? onChanged; + + const SettingsListSwitch({ + Key? key, + this.title, + this.subtitle, + this.onChanged, + required this.value, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return SwitchListTile.adaptive( + isThreeLine: subtitle != null, + title: Text(title!, style: const TextStyle(fontWeight: FontWeight.w500)), + subtitle: subtitle, + value: value, + onChanged: onChanged, + ); + } +} diff --git a/lib/screens/settings/widgets/settings_tile_route.dart b/lib/screens/settings/widgets/settings_tile_route.dart new file mode 100644 index 00000000..976df8de --- /dev/null +++ b/lib/screens/settings/widgets/settings_tile_route.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:frosty/widgets/app_bar.dart'; + +class SettingsTileRoute extends StatelessWidget { + final Widget leading; + final String title; + final Widget child; + + const SettingsTileRoute({ + Key? key, + required this.leading, + required this.title, + required this.child, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: leading, + title: Text(title, style: const TextStyle(fontWeight: FontWeight.w600)), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => Scaffold( + appBar: FrostyAppBar(title: Text(title)), + body: SafeArea( + bottom: false, + child: child, + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/alert_message.dart b/lib/widgets/alert_message.dart index b7c88d51..295fe84d 100644 --- a/lib/widgets/alert_message.dart +++ b/lib/widgets/alert_message.dart @@ -3,13 +3,11 @@ import 'package:flutter/material.dart'; /// A simple widget that displays an alert message in the center. class AlertMessage extends StatelessWidget { final String message; - final IconData icon; final Color? color; const AlertMessage({ Key? key, required this.message, - this.icon = Icons.info, this.color, }) : super(key: key); @@ -19,7 +17,7 @@ class AlertMessage extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - icon, + Icons.info_outline_rounded, color: color ?? Colors.grey, ), const SizedBox(width: 5.0), diff --git a/lib/widgets/animate_scale.dart b/lib/widgets/animate_scale.dart index 1580e286..de073a5b 100644 --- a/lib/widgets/animate_scale.dart +++ b/lib/widgets/animate_scale.dart @@ -26,8 +26,8 @@ class _AnimateScaleState extends State with SingleTickerProviderSt @override Widget build(BuildContext context) { const duration = Duration(milliseconds: 200); - const downCurve = Curves.easeOutCubic; - const upCurve = Curves.easeInCubic; + const downCurve = Curves.easeOut; + const upCurve = Curves.easeIn; return InkWell( onTap: widget.onTap, diff --git a/lib/widgets/app_bar.dart b/lib/widgets/app_bar.dart new file mode 100644 index 00000000..6c9aabfe --- /dev/null +++ b/lib/widgets/app_bar.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +class FrostyAppBar extends StatelessWidget with PreferredSizeWidget { + final Widget title; + final bool? centerTitle; + final List? actions; + + const FrostyAppBar({ + Key? key, + required this.title, + this.centerTitle, + this.actions, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return AppBar( + leading: IconButton( + tooltip: 'Back', + icon: Icon(Icons.adaptive.arrow_back_rounded), + onPressed: Navigator.of(context).pop, + ), + title: title, + centerTitle: centerTitle, + actions: actions, + ); + } + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); +} diff --git a/lib/widgets/block_button.dart b/lib/widgets/block_button.dart deleted file mode 100644 index 471b2339..00000000 --- a/lib/widgets/block_button.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:frosty/screens/settings/stores/auth_store.dart'; -import 'package:frosty/widgets/button.dart'; -import 'package:frosty/widgets/dialog.dart'; - -class BlockButton extends StatelessWidget { - final AuthStore authStore; - final String targetUser; - final String targetUserId; - final bool simple; - - const BlockButton({ - Key? key, - required this.authStore, - required this.targetUser, - required this.targetUserId, - this.simple = true, - }) : super(key: key); - - Future _showDialog( - BuildContext context, { - required bool isBlocked, - required String targetUser, - required String targetUserId, - }) { - return showDialog( - context: context, - builder: (context) => FrostyDialog( - title: isBlocked ? 'Unblock' : 'Block', - content: Text( - 'Are you sure you want to ${isBlocked ? 'unblock $targetUser?' : 'block $targetUser? This will remove them from channel lists, search results, and chat messages.'}'), - actions: [ - Button( - onPressed: () { - if (isBlocked) { - authStore.user.unblock(targetId: targetUserId, headers: authStore.headersTwitch); - } else { - authStore.user.block( - targetId: targetUserId, - displayName: targetUser, - headers: authStore.headersTwitch, - ); - } - Navigator.pop(context); - }, - child: const Text('Yes'), - ), - Button( - fill: true, - onPressed: Navigator.of(context).pop, - color: Colors.red.shade700, - child: const Text('Cancel'), - ), - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - return Observer( - builder: (context) { - final isBlocked = authStore.user.blockedUsers.where((blockedUser) => blockedUser.userId == targetUserId).isNotEmpty; - - return Button( - padding: const EdgeInsets.symmetric(horizontal: 15.0, vertical: 10.0), - icon: const Icon(Icons.block), - color: Colors.red.shade700, - fill: true, - onPressed: () => _showDialog( - context, - isBlocked: isBlocked, - targetUser: targetUser, - targetUserId: targetUserId, - ), - child: isBlocked ? Text(simple ? 'Unblock' : 'Unblock $targetUser') : Text(simple ? 'Block' : 'Block $targetUser'), - ); - }, - ); - } -} diff --git a/lib/widgets/block_report_modal.dart b/lib/widgets/block_report_modal.dart index e62cf9b8..40d576cf 100644 --- a/lib/widgets/block_report_modal.dart +++ b/lib/widgets/block_report_modal.dart @@ -1,10 +1,9 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; -import 'package:frosty/widgets/block_button.dart'; -import 'package:frosty/widgets/modal.dart'; -import 'package:frosty/widgets/report_button.dart'; +import 'package:frosty/widgets/app_bar.dart'; +import 'package:frosty/widgets/bottom_sheet.dart'; +import 'package:frosty/widgets/list_tile.dart'; +import 'package:webview_flutter/webview_flutter.dart'; class BlockReportModal extends StatelessWidget { final AuthStore authStore; @@ -22,31 +21,38 @@ class BlockReportModal extends StatelessWidget { @override Widget build(BuildContext context) { - return FrostyModal( + return FrostyBottomSheet( child: Column( mainAxisSize: MainAxisSize.min, children: [ if (authStore.isLoggedIn) - Container( - padding: const EdgeInsets.symmetric(horizontal: 10.0), - width: double.infinity, - child: BlockButton( - authStore: authStore, - targetUser: name, - targetUserId: userId, - simple: false, + FrostyListTile( + leading: const Icon(Icons.block_rounded), + onTap: () => authStore + .showBlockDialog(context, targetUser: name, targetUserId: userId) + .then((_) => Navigator.pop(context)), + title: 'Block $name', + ), + FrostyListTile( + leading: const Icon(Icons.outlined_flag_rounded), + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) { + return Scaffold( + appBar: FrostyAppBar( + title: Text('Report $name'), + ), + body: WebView( + initialUrl: 'https://www.twitch.tv/$userLogin/report', + javascriptMode: JavascriptMode.unrestricted, + ), + ); + }, ), ), - const SizedBox(height: 10.0), - Container( - padding: const EdgeInsets.symmetric(horizontal: 10.0), - width: double.infinity, - child: ReportButton( - userLogin: userLogin, - displayName: name, - ), - ), - if (Platform.isAndroid) const SizedBox(height: 20.0), + title: 'Report $name', + ) ], ), ); diff --git a/lib/widgets/modal.dart b/lib/widgets/bottom_sheet.dart similarity index 64% rename from lib/widgets/modal.dart rename to lib/widgets/bottom_sheet.dart index 59487714..8cfbe489 100644 --- a/lib/widgets/modal.dart +++ b/lib/widgets/bottom_sheet.dart @@ -1,19 +1,18 @@ import 'package:flutter/material.dart'; -class FrostyModal extends StatelessWidget { +class FrostyBottomSheet extends StatelessWidget { final Widget child; - const FrostyModal({Key? key, required this.child}) : super(key: key); + const FrostyBottomSheet({Key? key, required this.child}) : super(key: key); @override Widget build(BuildContext context) { return Container( - margin: const EdgeInsets.symmetric(horizontal: 15.0), decoration: BoxDecoration( color: Theme.of(context).dialogBackgroundColor, borderRadius: const BorderRadius.only( - topLeft: Radius.circular(20.0), - topRight: Radius.circular(20.0), + topLeft: Radius.circular(15.0), + topRight: Radius.circular(15.0), ), ), child: SafeArea( @@ -21,11 +20,11 @@ class FrostyModal extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ const FractionallySizedBox( - widthFactor: 0.25, + widthFactor: 0.2, child: SizedBox( child: Divider( - height: 25.0, - thickness: 3.0, + height: 30.0, + thickness: 2.0, ), ), ), diff --git a/lib/widgets/button.dart b/lib/widgets/button.dart index 1a45af78..42ec204b 100644 --- a/lib/widgets/button.dart +++ b/lib/widgets/button.dart @@ -40,8 +40,8 @@ class _ButtonState extends State