diff --git a/lib/api/bttv_api.dart b/lib/api/bttv_api.dart index 0c017cee..dfb252ec 100644 --- a/lib/api/bttv_api.dart +++ b/lib/api/bttv_api.dart @@ -21,7 +21,7 @@ class BTTVApi { return emotes.map((emote) => Emote.fromBTTV(emote, EmoteType.bttvGlobal)).toList(); } else { - return Future.error('Failed to get BTTV global emotes.'); + return Future.error('Failed to get BTTV global emotes'); } } @@ -40,7 +40,7 @@ class BTTVApi { return emoteToUrl; } else { - return Future.error('Failed to get BTTV channel emotes.'); + return Future.error('Failed to get BTTV channel emotes'); } } diff --git a/lib/api/ffz_api.dart b/lib/api/ffz_api.dart index 1249466c..c3d2c892 100644 --- a/lib/api/ffz_api.dart +++ b/lib/api/ffz_api.dart @@ -28,7 +28,7 @@ class FFZApi { return emotes.map((emote) => Emote.fromFFZ(emote, EmoteType.ffzGlobal)).toList(); } else { - return Future.error('Failed to get FFZ global emotes.'); + return Future.error('Failed to get FFZ global emotes'); } } @@ -46,7 +46,7 @@ class FFZApi { return Tuple2(roomInfo, emotes.map((emote) => Emote.fromFFZ(emote, EmoteType.ffzChannel)).toList()); } else { - return Future.error('Failed to get FFZ room info.'); + return Future.error('Failed to get FFZ room info'); } } @@ -75,7 +75,7 @@ class FFZApi { return result; } else { - return Future.error('Failed to get FFZ badges.'); + return Future.error('Failed to get FFZ badges'); } } } diff --git a/lib/api/seventv_api.dart b/lib/api/seventv_api.dart index 0033c99c..133ab32d 100644 --- a/lib/api/seventv_api.dart +++ b/lib/api/seventv_api.dart @@ -64,7 +64,7 @@ class SevenTVApi { return result; } else { - return Future.error('Failed to get 7TV badges.'); + return Future.error('Failed to get 7TV badges'); } } } diff --git a/lib/api/twitch_api.dart b/lib/api/twitch_api.dart index a72a8d52..3ec02566 100644 --- a/lib/api/twitch_api.dart +++ b/lib/api/twitch_api.dart @@ -28,7 +28,7 @@ class TwitchApi { return emotes.map((emote) => Emote.fromTwitch(emote, EmoteType.twitchGlobal)).toList(); } else { - return Future.error('Failed to get Twitch global emotes.'); + return Future.error('Failed to get Twitch global emotes'); } } @@ -57,7 +57,7 @@ class TwitchApi { } }).toList(); } else { - return Future.error('Failed to get Twitch channel emotes.'); + return Future.error('Failed to get Twitch channel emotes'); } } @@ -85,7 +85,7 @@ class TwitchApi { } }).toList(); } else { - return Future.error('Failed to get Twitch emotes set.'); + return Future.error('Failed to get Twitch emotes set'); } } @@ -97,12 +97,12 @@ class TwitchApi { if (response.statusCode == 200) { final result = {}; final decoded = jsonDecode(response.body)['badge_sets'] as Map; - decoded.forEach((id, versions) => (versions['versions'] as Map) - .forEach((version, badgeInfo) => result['$id/$version'] = Badge.fromTwitch(BadgeInfoTwitch.fromJson(badgeInfo)))); + decoded.forEach((id, versions) => + (versions['versions'] as Map).forEach((version, badgeInfo) => result['$id/$version'] = Badge.fromTwitch(BadgeInfoTwitch.fromJson(badgeInfo)))); return result; } else { - return Future.error('Failed to get Twitch global badges.'); + return Future.error('Failed to get Twitch global badges'); } } @@ -114,12 +114,12 @@ class TwitchApi { if (response.statusCode == 200) { final result = {}; final decoded = jsonDecode(response.body)['badge_sets'] as Map; - decoded.forEach((id, versions) => (versions['versions'] as Map) - .forEach((version, badgeInfo) => result['$id/$version'] = Badge.fromTwitch(BadgeInfoTwitch.fromJson(badgeInfo)))); + decoded.forEach((id, versions) => + (versions['versions'] as Map).forEach((version, badgeInfo) => result['$id/$version'] = Badge.fromTwitch(BadgeInfoTwitch.fromJson(badgeInfo)))); return result; } else { - return Future.error('Failed to get Twitch channel badges.'); + return Future.error('Failed to get Twitch channel badges'); } } @@ -154,7 +154,7 @@ class TwitchApi { if (response.statusCode == 200) { return jsonDecode(response.body)['access_token']; } else { - return Future.error('Failed to get default token.'); + return Future.error('Failed to get default token'); } } @@ -164,10 +164,8 @@ class TwitchApi { final response = await _client.get(url, headers: {'Authorization': 'Bearer $token'}); if (response.statusCode == 200) { - debugPrint('Token validated!'); return true; } else { - debugPrint('Token invalidated :('); return false; } } @@ -194,9 +192,8 @@ class TwitchApi { required Map headers, String? cursor, }) async { - final url = Uri.parse(cursor == null - ? 'https://api.twitch.tv/helix/streams/followed?user_id=$id' - : 'https://api.twitch.tv/helix/streams/followed?user_id=$id&after=$cursor'); + final url = Uri.parse( + cursor == null ? 'https://api.twitch.tv/helix/streams/followed?user_id=$id' : 'https://api.twitch.tv/helix/streams/followed?user_id=$id&after=$cursor'); final response = await _client.get(url, headers: headers); final decoded = jsonDecode(response.body); @@ -213,8 +210,8 @@ class TwitchApi { required Map headers, String? cursor, }) async { - final url = Uri.parse( - cursor == null ? 'https://api.twitch.tv/helix/streams?game_id=$gameId' : 'https://api.twitch.tv/helix/streams?game_id=$gameId&after=$cursor'); + final url = + Uri.parse(cursor == null ? 'https://api.twitch.tv/helix/streams?game_id=$gameId' : 'https://api.twitch.tv/helix/streams?game_id=$gameId&after=$cursor'); final response = await _client.get(url, headers: headers); final decoded = jsonDecode(response.body); @@ -243,7 +240,7 @@ class TwitchApi { return Future.error('$userLogin is offline'); } } else { - return Future.error('Failed to get stream info.'); + return Future.error('Failed to get stream info'); } } @@ -264,7 +261,7 @@ class TwitchApi { if (userData.isNotEmpty) { return UserTwitch.fromJson(userData.first); } else { - return Future.error('User does not exist!'); + return Future.error('User does not exist'); } } else { return Future.error('Failed to get user: ${decoded['message']}'); @@ -306,7 +303,7 @@ class TwitchApi { return channelData.map((e) => ChannelQuery.fromJson(e)).toList(); } else { - return Future.error('Failed to get channels.'); + return Future.error('Failed to get channels'); } } @@ -358,7 +355,7 @@ class TwitchApi { return CategoriesTwitch.fromJson(decoded); } else { - return Future.error('Failed to get categories.'); + return Future.error('Failed to get categories'); } } @@ -375,7 +372,7 @@ class TwitchApi { return decoded['total'] as int; } else { - return Future.error('Failed to get sub count.'); + return Future.error('Failed to get sub count'); } } @@ -389,7 +386,7 @@ class TwitchApi { return ChatUsers.fromJson(decoded); } else { - return Future.error('Failed to get chatters.'); + return Future.error('Failed to get chatters'); } } @@ -422,7 +419,7 @@ class TwitchApi { return []; } } else { - return Future.error('User does not exist!'); + return Future.error('User does not exist'); } } diff --git a/lib/main.dart b/lib/main.dart index 084a1491..56fa846e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -78,6 +78,23 @@ final navigatorKey = GlobalKey(); const gray = Color.fromRGBO(22, 22, 22, 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)), + borderSide: BorderSide(style: BorderStyle.none), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(30.0)), + borderSide: BorderSide(style: BorderStyle.none), + ), + disabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(30.0)), + borderSide: BorderSide(style: BorderStyle.none), + ), +); + final lightTheme = ThemeData( canvasColor: Colors.white, splashFactory: Platform.isIOS ? NoSplash.splashFactory : null, @@ -102,6 +119,7 @@ final lightTheme = ThemeData( labelColor: Colors.black, unselectedLabelColor: Colors.grey, ), + inputDecorationTheme: inputTheme, ); final darkTheme = ThemeData( @@ -126,6 +144,7 @@ final darkTheme = ThemeData( ), dialogBackgroundColor: gray, toggleableActiveColor: purple, + inputDecorationTheme: inputTheme, ); final oledTheme = ThemeData( @@ -148,6 +167,7 @@ final oledTheme = ThemeData( ), dialogBackgroundColor: Colors.black, toggleableActiveColor: purple, + inputDecorationTheme: inputTheme, ); class MyApp extends StatelessWidget { diff --git a/lib/screens/channel/chat/chat.dart b/lib/screens/channel/chat/chat.dart index b8f7765e..2b738741 100644 --- a/lib/screens/channel/chat/chat.dart +++ b/lib/screens/channel/chat/chat.dart @@ -1,10 +1,12 @@ 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/widgets/chat_bottom_bar.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_message.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_user_modal.dart'; import 'package:frosty/screens/channel/stores/chat_store.dart'; +import 'package:frosty/widgets/button.dart'; class Chat extends StatelessWidget { final ChatStore chatStore; @@ -15,10 +17,6 @@ class Chat extends StatelessWidget { Widget build(BuildContext context) { return Observer( builder: (context) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (chatStore.scrollController.hasClients) chatStore.scrollController.jumpTo(chatStore.scrollController.position.maxScrollExtent); - }); - return Column( children: [ Expanded( @@ -68,15 +66,21 @@ class Chat extends StatelessWidget { FocusScope.of(context).unfocus(); if (chatStore.assetsStore.showEmoteMenu) chatStore.assetsStore.showEmoteMenu = false; }, - onLongPress: () => showModalBottomSheet( - context: context, - builder: (context) => ChatUserModal( - chatStore: chatStore, - username: message.user!, - userId: message.tags['user-id']!, - displayName: message.tags['display-name']!, - ), - ), + onLongPress: () { + HapticFeedback.lightImpact(); + + showModalBottomSheet( + backgroundColor: Colors.transparent, + isScrollControlled: true, + context: context, + builder: (context) => ChatUserModal( + chatStore: chatStore, + username: message.user!, + userId: message.tags['user-id']!, + displayName: message.tags['display-name']!, + ), + ); + }, child: chatMessage, ); } @@ -88,20 +92,22 @@ class Chat extends StatelessWidget { ), ), ), - Observer( - builder: (_) => AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - child: chatStore.autoScroll - ? null - : Container( - padding: const EdgeInsets.symmetric(horizontal: 10.0), - width: double.infinity, - child: ElevatedButton.icon( + Padding( + padding: const EdgeInsets.all(5.0), + child: Observer( + builder: (_) => AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + child: chatStore.autoScroll + ? null + : Button( + padding: const EdgeInsets.symmetric(horizontal: 15.0, vertical: 10.0), onPressed: chatStore.resumeScroll, - label: const Text('Resume Scroll'), icon: const Icon(Icons.arrow_circle_down), + child: const Text('Resume Scroll'), ), - ), + ), ), ), ], @@ -109,11 +115,18 @@ class Chat extends StatelessWidget { ), ), if (chatStore.settings.showBottomBar) ChatBottomBar(chatStore: chatStore), - if (chatStore.assetsStore.showEmoteMenu) - SizedBox( - height: MediaQuery.of(context).size.height / 3, + AnimatedContainer( + curve: Curves.ease, + duration: const Duration(milliseconds: 200), + height: chatStore.assetsStore.showEmoteMenu ? MediaQuery.of(context).size.height / 3 : 0, + child: AnimatedOpacity( + curve: Curves.ease, + opacity: chatStore.assetsStore.showEmoteMenu ? 1 : 0, + duration: const Duration(milliseconds: 200), child: EmoteMenu(chatStore: chatStore), ), + onEnd: () => chatStore.scrollController.jumpTo(chatStore.scrollController.position.maxScrollExtent), + ), ], ); }, diff --git a/lib/screens/channel/chat/details/chat_details.dart b/lib/screens/channel/chat/details/chat_details.dart index b9f0cbea..dd9f5efb 100644 --- a/lib/screens/channel/chat/details/chat_details.dart +++ b/lib/screens/channel/chat/details/chat_details.dart @@ -4,39 +4,43 @@ 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/stores/chat_details_store.dart'; import 'package:frosty/screens/channel/stores/chat_store.dart'; +import 'package:frosty/widgets/modal.dart'; class ChatDetails extends StatelessWidget { - final ChatDetailsStore chatDetails; + final ChatDetailsStore chatDetailsStore; final ChatStore chatStore; final String userLogin; const ChatDetails({ Key? key, - required this.chatDetails, + required this.chatDetailsStore, required this.chatStore, required this.userLogin, }) : super(key: key); @override Widget build(BuildContext context) { - return GestureDetector( - onTap: FocusScope.of(context).unfocus, - child: SafeArea( - child: Observer( - builder: (_) => Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 15.0), - child: ChatModes(roomState: chatDetails.roomState), - ), - Expanded( - child: ChattersList( - chatDetails: chatDetails, - chatStore: chatStore, - userLogin: userLogin, + return FrostyModal( + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.8, + child: GestureDetector( + onTap: FocusScope.of(context).unfocus, + child: Observer( + builder: (_) => Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 15.0), + child: ChatModes(roomState: chatDetailsStore.roomState), ), - ), - ], + Expanded( + child: ChattersList( + chatDetailsStore: chatDetailsStore, + chatStore: chatStore, + userLogin: userLogin, + ), + ), + ], + ), ), ), ), diff --git a/lib/screens/channel/chat/details/chat_modes.dart b/lib/screens/channel/chat/details/chat_modes.dart index d03686bd..3c4d2c6a 100644 --- a/lib/screens/channel/chat/details/chat_modes.dart +++ b/lib/screens/channel/chat/details/chat_modes.dart @@ -1,19 +1,26 @@ import 'package:flutter/material.dart'; import 'package:frosty/models/irc.dart'; +import 'package:intl/intl.dart'; class ChatModes extends StatelessWidget { final ROOMSTATE roomState; const ChatModes({Key? key, required this.roomState}) : super(key: key); + String pluralize(String str, String count) => count == '1' ? str : '${str}s'; + @override Widget build(BuildContext context) { + final test = Intl.plural(2, other: 'test'); + + debugPrint(test); + return Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ Tooltip( preferBelow: false, - message: 'Emote-Only Mode: ${roomState.emoteOnly != '0' ? 'On' : 'Off'}', + message: 'Emote-only mode ${roomState.emoteOnly != '0' ? 'on' : 'off'}', child: Icon( Icons.emoji_emotions_outlined, color: roomState.emoteOnly != '0' ? Colors.yellow : Colors.grey, @@ -22,12 +29,10 @@ class ChatModes extends StatelessWidget { Tooltip( preferBelow: false, message: roomState.followersOnly == '-1' - ? 'Followers-Only Mode: Off' + ? 'Followers-only mode off' : roomState.followersOnly == '0' - ? 'Followers-Only Mode: On' - : roomState.followersOnly == '1' - ? 'Followers-Only Mode: On (${roomState.followersOnly} minute)' - : 'Followers-Only Mode: On (${roomState.followersOnly} minutes)', + ? 'Followers-only mode on' + : 'Followers-only mode on (${roomState.followersOnly} ${pluralize('minute', roomState.followersOnly)})', child: Icon( Icons.favorite, color: roomState.followersOnly != '-1' ? Colors.red : Colors.grey, @@ -35,7 +40,7 @@ class ChatModes extends StatelessWidget { ), Tooltip( preferBelow: false, - message: 'R9K Mode: ${roomState.r9k != '0' ? 'On' : 'Off'}', + message: 'R9K mode ${roomState.r9k != '0' ? 'on' : 'off'}', child: Text( 'R9K', style: TextStyle(color: roomState.r9k != '0' ? Colors.purple : Colors.grey), @@ -43,7 +48,7 @@ class ChatModes extends StatelessWidget { ), Tooltip( preferBelow: false, - message: 'Slow Mode: ${roomState.slowMode != '0' ? 'On (${roomState.slowMode} seconds)' : 'Off'}', + message: 'Slow mode ${roomState.slowMode != '0' ? 'on (${roomState.slowMode} ${pluralize('second', roomState.slowMode)})' : 'off'}', child: Icon( Icons.history_toggle_off, color: roomState.slowMode != '0' ? Colors.blue : Colors.grey, @@ -51,7 +56,7 @@ class ChatModes extends StatelessWidget { ), Tooltip( preferBelow: false, - message: 'Subs-Only Mode: ${roomState.subMode != '0' ? 'On' : 'Off'}', + message: 'Subs-only mode ${roomState.subMode != '0' ? 'on' : 'off'}', child: Icon( Icons.attach_money, 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 9faea78a..f7ad891f 100644 --- a/lib/screens/channel/chat/details/chat_users_list.dart +++ b/lib/screens/channel/chat/details/chat_users_list.dart @@ -7,46 +7,26 @@ import 'package:frosty/core/auth/auth_store.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_user_modal.dart'; import 'package:frosty/screens/channel/stores/chat_details_store.dart'; import 'package:frosty/screens/channel/stores/chat_store.dart'; +import 'package:frosty/widgets/alert_message.dart'; +import 'package:frosty/widgets/button.dart'; import 'package:frosty/widgets/loading_indicator.dart'; import 'package:frosty/widgets/scroll_to_top_button.dart'; import 'package:frosty/widgets/section_header.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; -class ChattersList extends StatefulWidget { - final ChatDetailsStore chatDetails; +class ChattersList extends StatelessWidget { + final ChatDetailsStore chatDetailsStore; final ChatStore chatStore; final String userLogin; const ChattersList({ Key? key, - required this.chatDetails, + required this.chatDetailsStore, required this.chatStore, required this.userLogin, }) : super(key: key); - @override - State createState() => _ChattersListState(); -} - -class _ChattersListState extends State { - final _scrollController = ScrollController(); - final _textController = TextEditingController(); - - @override - void initState() { - super.initState(); - _scrollController.addListener(() { - if (_scrollController.position.atEdge || _scrollController.position.outOfRange) { - widget.chatDetails.showJumpButton = false; - } else { - widget.chatDetails.showJumpButton = true; - } - }); - - widget.chatDetails.updateChatters(widget.userLogin); - } - @override Widget build(BuildContext context) { const headers = [ @@ -59,147 +39,173 @@ class _ChattersListState extends State { 'Viewers', ]; - final chatDetailsStore = widget.chatDetails; - - return Column( - children: [ - Padding( - padding: const EdgeInsets.all(10.0), - child: TextField( - controller: _textController, - autocorrect: false, - onChanged: (text) => chatDetailsStore.filterText = text, - decoration: InputDecoration( - isDense: true, - labelText: 'Filter chatters', - contentPadding: const EdgeInsets.all(10.0), - suffixIcon: IconButton( - tooltip: 'Clear Filter', - onPressed: () { - FocusScope.of(context).unfocus(); - chatDetailsStore.filterText = ''; - _textController.clear(); - }, - icon: const Icon(Icons.clear), + return Observer( + builder: (context) { + if (chatDetailsStore.error != null) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const AlertMessage( + message: 'Failed to get chatters', + icon: Icons.error, ), - border: const OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(15.0)), + const SizedBox(height: 10.0), + Button( + padding: const EdgeInsets.symmetric(horizontal: 15.0), + onPressed: chatDetailsStore.updateChatters, + child: const Text('Try Again'), + ) + ], + ); + } + + if (chatDetailsStore.chatUsers == null) { + return const LoadingIndicator(subtitle: 'Getting chatters...'); + } + + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(10.0), + child: Observer( + builder: (context) { + return TextField( + controller: chatDetailsStore.textController, + focusNode: chatDetailsStore.textFieldFocusNode, + autocorrect: false, + decoration: InputDecoration( + contentPadding: EdgeInsets.zero, + prefixIcon: const Icon(Icons.filter_list), + hintText: 'Filter chatters', + suffixIcon: chatDetailsStore.textFieldFocusNode.hasFocus || chatDetailsStore.filterText.isNotEmpty + ? IconButton( + tooltip: chatDetailsStore.filterText.isEmpty ? 'Cancel' : 'Clear', + onPressed: () { + if (chatDetailsStore.filterText.isEmpty) chatDetailsStore.textFieldFocusNode.unfocus(); + chatDetailsStore.textController.clear(); + }, + icon: const Icon(Icons.clear), + ) + : null, + ), + ); + }, ), ), - ), - ), - Expanded( - child: RefreshIndicator( - onRefresh: () async { - HapticFeedback.lightImpact(); - await chatDetailsStore.updateChatters(widget.userLogin); - }, - child: Stack( - alignment: AlignmentDirectional.bottomCenter, - children: [ - Observer( - builder: (context) { - if (chatDetailsStore.error != null) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('Failed to get chatters'), - TextButton( - onPressed: () => chatDetailsStore.updateChatters(widget.userLogin), - child: const Text('Try Again'), - ) - ], - ); - } + Expanded( + child: RefreshIndicator( + onRefresh: () async { + HapticFeedback.lightImpact(); - if (chatDetailsStore.chatUsers == null) { - return const LoadingIndicator(subtitle: Text('Getting chatters...')); - } - - return CustomScrollView( - physics: const AlwaysScrollableScrollPhysics(), - controller: _scrollController, - slivers: [ - 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'}', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 18.0, - ), - ), - ), - ), - ...chatDetailsStore.filteredUsers.expandIndexed( - (index, users) => [ - if (users.isNotEmpty) ...[ + chatDetailsStore.updateChatters(); + }, + child: Stack( + alignment: AlignmentDirectional.bottomCenter, + children: [ + Observer( + builder: (context) { + return CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + controller: chatDetailsStore.scrollController, + slivers: [ + if (chatDetailsStore.filterText.isEmpty) SliverPadding( padding: const EdgeInsets.only(left: 10.0, right: 10.0, top: 20.0), sliver: SliverToBoxAdapter( - child: SectionHeader( - headers[index], - fontSize: 12.0, - padding: const EdgeInsets.all(0.0), - ), - ), - ), - SliverPadding( - padding: const EdgeInsets.only(left: 10.0, right: 10.0, top: 5.0), - sliver: SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) => InkWell( - child: Text(users[index]), - onLongPress: () async { - final userInfo = await context - .read() - .getUser(headers: context.read().headersTwitch, userLogin: users[index]); - - showModalBottomSheet( - context: context, - builder: (context) => ChatUserModal( - chatStore: widget.chatStore, - username: userInfo.login, - userId: userInfo.id, - displayName: userInfo.displayName, - ), - ); - }, + child: Text( + '${NumberFormat().format(chatDetailsStore.chatUsers?.chatterCount)} ${chatDetailsStore.chatUsers?.chatterCount == 1 ? 'Chatter' : 'Chatters'}', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18.0, ), - childCount: users.length, ), ), ), - ] + if (chatDetailsStore.chatUsers?.chatterCount == 0) + const SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: AlertMessage( + message: 'No chatters found', + icon: Icons.person_off, + ), + ), + ) + else if (chatDetailsStore.filteredUsers.expand((element) => element).isEmpty) + const SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: AlertMessage( + message: 'No matching chatters', + icon: Icons.person_off, + ), + ), + ) + else + ...chatDetailsStore.filteredUsers.expandIndexed( + (index, users) => [ + if (users.isNotEmpty) ...[ + SliverPadding( + padding: const EdgeInsets.only(left: 10.0, right: 10.0, top: 20.0), + sliver: SliverToBoxAdapter( + child: SectionHeader( + headers[index], + padding: const EdgeInsets.symmetric(vertical: 5.0), + ), + ), + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => InkWell( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 5.0), + child: Text(users[index]), + ), + onLongPress: () async { + HapticFeedback.lightImpact(); + + 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, + username: userInfo.login, + userId: userInfo.id, + displayName: userInfo.displayName, + ), + ); + }, + ), + childCount: users.length, + ), + ), + ] + ], + ), ], - ) - ], - ); - }, - ), - SafeArea( - child: Observer( - builder: (context) => AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - child: chatDetailsStore.showJumpButton ? ScrollToTopButton(scrollController: _scrollController) : null, + ); + }, ), - ), + Observer( + builder: (context) => AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + child: chatDetailsStore.showJumpButton ? ScrollToTopButton(scrollController: chatDetailsStore.scrollController) : null, + ), + ), + ], ), - ], + ), ), - ), - ), - ], + ], + ); + }, ); } - - @override - void dispose() { - _scrollController.dispose(); - _textController.dispose(); - widget.chatDetails.filterText = ''; - - super.dispose(); - } } diff --git a/lib/screens/channel/chat/emote_menu/emote_menu.dart b/lib/screens/channel/chat/emote_menu/emote_menu.dart index f21440a3..6f9a591c 100644 --- a/lib/screens/channel/chat/emote_menu/emote_menu.dart +++ b/lib/screens/channel/chat/emote_menu/emote_menu.dart @@ -4,6 +4,7 @@ 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/stores/chat_store.dart'; +import 'package:frosty/widgets/button.dart'; class EmoteMenu extends StatefulWidget { final ChatStore chatStore; @@ -31,28 +32,36 @@ class _EmoteMenuState extends State { ]; return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Divider( + height: 5.0, + thickness: 1.0, + ), SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: sections .mapIndexed( - (index, section) => Observer( - builder: (context) => TextButton( - onPressed: () { - _pageContoller.animateToPage( - index, - duration: const Duration(milliseconds: 200), - curve: Curves.ease, - ); - widget.chatStore.assetsStore.emoteMenuIndex = index; - }, - style: index == widget.chatStore.assetsStore.emoteMenuIndex ? null : TextButton.styleFrom(primary: Colors.grey), - child: Text( - section, - style: const TextStyle( - letterSpacing: 0.5, - fontWeight: FontWeight.w600, + (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, + ), ), ), ), 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 e0001bb3..1409e025 100644 --- a/lib/screens/channel/chat/emote_menu/recent_emotes_panel.dart +++ b/lib/screens/channel/chat/emote_menu/recent_emotes_panel.dart @@ -3,6 +3,8 @@ import 'package:flutter/material.dart'; import 'package:frosty/constants/constants.dart'; import 'package:frosty/screens/channel/stores/chat_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; +import 'package:frosty/widgets/button.dart'; +import 'package:frosty/widgets/dialog.dart'; import 'package:frosty/widgets/section_header.dart'; import 'package:provider/provider.dart'; @@ -22,22 +24,23 @@ class _RecentEmotesPanelState extends State { Future _showClearDialog() { return showDialog( context: context, - builder: (context) => AlertDialog( - title: const Text('Clear Recent Emotes'), + builder: (context) => FrostyDialog( + title: 'Clear Recent Emotes', content: const Text('Are you sure you want to clear your recent emotes?'), actions: [ - TextButton( - onPressed: Navigator.of(context).pop, - style: TextButton.styleFrom(primary: Colors.red), - child: const Text('Cancel'), - ), - ElevatedButton( + 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'), + ), ], ), ); @@ -55,14 +58,17 @@ class _RecentEmotesPanelState extends State { padding: EdgeInsets.symmetric(vertical: 5.0, horizontal: 10.0), ), const Spacer(), - SizedBox( - height: 30, - child: TextButton( + 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.5, + letterSpacing: 0.8, fontWeight: FontWeight.w500, ), ), diff --git a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart index cba6191e..e95815a0 100644 --- a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart +++ b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart @@ -4,6 +4,7 @@ import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/constants/constants.dart'; import 'package:frosty/screens/channel/chat/details/chat_details.dart'; import 'package:frosty/screens/channel/stores/chat_store.dart'; +import 'package:frosty/widgets/button.dart'; class ChatBottomBar extends StatelessWidget { final ChatStore chatStore; @@ -70,7 +71,8 @@ class ChatBottomBar extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 5.0), itemCount: matchingChatters.length, scrollDirection: Axis.horizontal, - itemBuilder: (context, index) => TextButton( + itemBuilder: (context, index) => Button( + color: Theme.of(context).colorScheme.secondary, onPressed: () { final split = chatStore.textController.text.split(' ') ..removeLast() @@ -84,71 +86,77 @@ class ChatBottomBar extends StatelessWidget { ), ) ], - Row( - children: [ - IconButton( - icon: Icon(Icons.adaptive.more), - tooltip: 'Chat Details', - onPressed: () => showModalBottomSheet( - isScrollControlled: true, - context: context, - builder: (_) => SizedBox( - height: MediaQuery.of(context).size.height * 0.8, - child: ChatDetails( - chatDetails: chatStore.chatDetailsStore, - chatStore: chatStore, - userLogin: chatStore.channelName, - ), - ), - ), - ), - if (!chatStore.expandChat && chatStore.settings.chatWidth < 0.3 && MediaQuery.of(context).orientation == Orientation.landscape) - IconButton( - tooltip: 'Send a Message', + Padding( + padding: const EdgeInsets.symmetric(vertical: 5.0), + child: Row( + children: [ + if (!chatStore.expandChat && + chatStore.settings.chatWidth < 0.3 && + chatStore.settings.showVideo && + MediaQuery.of(context).orientation == Orientation.landscape) + IconButton( + tooltip: 'Send a message', onPressed: () { chatStore.expandChat = true; chatStore.textFieldFocusNode.requestFocus(); }, - icon: const Icon(Icons.chat)) - else - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 5.0, horizontal: 10.0), - child: TextField( - textInputAction: TextInputAction.send, - focusNode: chatStore.textFieldFocusNode, - minLines: 1, - maxLines: 5, - enabled: chatStore.auth.isLoggedIn ? true : false, - decoration: InputDecoration( - suffixIcon: IconButton( - color: chatStore.assetsStore.showEmoteMenu ? Colors.deepPurple : null, - tooltip: 'Emote Menu', - icon: const Icon(Icons.emoji_emotions_outlined), - onPressed: () { - FocusScope.of(context).unfocus(); - chatStore.assetsStore.showEmoteMenu = !chatStore.assetsStore.showEmoteMenu; - }, + icon: const Icon(Icons.chat), + ) + else + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 5.0, horizontal: 10.0), + child: TextField( + textInputAction: TextInputAction.send, + focusNode: chatStore.textFieldFocusNode, + minLines: 1, + maxLines: 5, + enabled: chatStore.auth.isLoggedIn ? true : false, + decoration: InputDecoration( + contentPadding: const EdgeInsets.fromLTRB(15.0, 10.0, 0.0, 10.0), + suffixIcon: 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; + }, + ), + hintMaxLines: 1, + hintText: chatStore.auth.isLoggedIn + ? 'Send a message ${chatStore.settings.chatDelay == 0 ? '' : '(${chatStore.settings.chatDelay.toInt()}s delay)'}' + : 'Log in to chat', ), - hintMaxLines: 1, - contentPadding: const EdgeInsets.all(10.0), - border: const OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(15.0)), - ), - labelText: chatStore.auth.isLoggedIn ? 'Send a message' : 'Log in to chat', + controller: chatStore.textController, + onSubmitted: chatStore.sendMessage, ), - controller: chatStore.textController, - onSubmitted: chatStore.sendMessage, ), ), - ), - if (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, - ) - ], + if (chatStore.showSendButton && + (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, + ) + else + IconButton( + icon: Icon(Icons.adaptive.more), + tooltip: 'Chat details', + onPressed: () => showModalBottomSheet( + backgroundColor: Colors.transparent, + isScrollControlled: true, + context: context, + builder: (_) => ChatDetails( + chatDetailsStore: chatStore.chatDetailsStore, + chatStore: chatStore, + userLogin: chatStore.channelName, + ), + ), + ), + ], + ), ), ], ); diff --git a/lib/screens/channel/chat/widgets/chat_message.dart b/lib/screens/channel/chat/widgets/chat_message.dart index a74ef44e..cc463fb5 100644 --- a/lib/screens/channel/chat/widgets/chat_message.dart +++ b/lib/screens/channel/chat/widgets/chat_message.dart @@ -87,12 +87,12 @@ class ChatMessage extends StatelessWidget { if (banDuration == null) if (ircMessage.command == Command.clearMessage) const Text( - 'Message Deleted', + 'Message deleted', style: TextStyle(fontWeight: FontWeight.bold), ) else const Text( - 'User Permanently Banned', + 'User permanently banned', style: TextStyle(fontWeight: FontWeight.bold), ) else diff --git a/lib/screens/channel/chat/widgets/chat_user_modal.dart b/lib/screens/channel/chat/widgets/chat_user_modal.dart index c2f9f1ab..46164469 100644 --- a/lib/screens/channel/chat/widgets/chat_user_modal.dart +++ b/lib/screens/channel/chat/widgets/chat_user_modal.dart @@ -1,8 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:frosty/constants/constants.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_message.dart'; import 'package:frosty/screens/channel/stores/chat_store.dart'; +import 'package:frosty/widgets/alert_message.dart'; import 'package:frosty/widgets/block_report_modal.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'; @@ -28,10 +32,11 @@ class _ChatUserModalState extends State { @override Widget build(BuildContext context) { final userMessages = widget.chatStore.messages.reversed.where((message) => message.user == widget.username).toList(); + final name = regexEnglish.hasMatch(widget.displayName) ? widget.displayName : '${widget.displayName} (${widget.username})'; - return Scaffold( - body: SafeArea( - bottom: false, + return FrostyModal( + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.5, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -42,27 +47,22 @@ class _ChatUserModalState extends State { ), title: Row( children: [ - Text( - widget.displayName, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - IconButton( - tooltip: 'Block or Report User', - onPressed: () => showModalBottomSheet( - context: context, - builder: (context) => BlockReportModal( - authStore: widget.chatStore.auth, - name: widget.displayName, - userLogin: widget.username, - userId: widget.userId, + Flexible( + child: Tooltip( + preferBelow: false, + message: name, + child: Text( + name, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.bold), ), ), - icon: Icon(Icons.adaptive.more), ), ], ), trailing: widget.chatStore.auth.isLoggedIn - ? OutlinedButton( + ? Button( + padding: const EdgeInsets.symmetric(horizontal: 15.0), onPressed: () { widget.chatStore.textController.text = '@${widget.username} '; Navigator.pop(context); @@ -71,47 +71,67 @@ 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, + ), + ); + }, ), const SectionHeader( 'Recent Messages', padding: EdgeInsets.all(10.0), ), - Expanded( - child: MediaQuery( - data: MediaQuery.of(context).copyWith(textScaleFactor: widget.chatStore.settings.messageScale), - child: DefaultTextStyle( - style: DefaultTextStyle.of(context).style.copyWith(fontSize: widget.chatStore.settings.fontSize), - child: ListView.separated( - reverse: true, - itemBuilder: (context, index) => InkWell( - onLongPress: () async { - await Clipboard.setData(ClipboardData(text: userMessages[index].message)); + if (userMessages.isEmpty) + const Expanded(child: AlertMessage(message: 'No recent messages')) + else + Expanded( + child: MediaQuery( + data: MediaQuery.of(context).copyWith(textScaleFactor: widget.chatStore.settings.messageScale), + child: DefaultTextStyle( + style: DefaultTextStyle.of(context).style.copyWith(fontSize: widget.chatStore.settings.fontSize), + child: ListView.separated( + padding: EdgeInsets.zero, + reverse: true, + itemBuilder: (context, index) => InkWell( + onLongPress: () async { + HapticFeedback.lightImpact(); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Message copied!'), - behavior: SnackBarBehavior.floating, - ), - ); - }, - child: ChatMessage( - ircMessage: userMessages[index], - assetsStore: widget.chatStore.assetsStore, - settingsStore: widget.chatStore.settings, + await Clipboard.setData(ClipboardData(text: userMessages[index].message)); + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: AlertMessage(message: 'Message copied to clipboard'), + behavior: SnackBarBehavior.floating, + ), + ); + }, + child: ChatMessage( + ircMessage: userMessages[index], + assetsStore: widget.chatStore.assetsStore, + settingsStore: widget.chatStore.settings, + ), ), + separatorBuilder: (context, index) => widget.chatStore.settings.showChatMessageDividers + ? Divider( + height: widget.chatStore.settings.messageSpacing, + thickness: 1.0, + ) + : SizedBox(height: widget.chatStore.settings.messageSpacing), + itemCount: userMessages.length, ), - separatorBuilder: (context, index) => widget.chatStore.settings.showChatMessageDividers - ? Divider( - height: widget.chatStore.settings.messageSpacing, - thickness: 1.0, - ) - : SizedBox(height: widget.chatStore.settings.messageSpacing), - itemCount: userMessages.length, ), ), ), - ), ], ), ), diff --git a/lib/screens/channel/stores/chat_details_store.dart b/lib/screens/channel/stores/chat_details_store.dart index a89f277d..d00bd497 100644 --- a/lib/screens/channel/stores/chat_details_store.dart +++ b/lib/screens/channel/stores/chat_details_store.dart @@ -1,6 +1,6 @@ import 'dart:io'; -import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; import 'package:frosty/api/twitch_api.dart'; import 'package:frosty/models/chatters.dart'; import 'package:frosty/models/irc.dart'; @@ -13,6 +13,17 @@ class ChatDetailsStore = ChatDetailsStoreBase with _$ChatDetailsStore; abstract class ChatDetailsStoreBase with Store { final TwitchApi twitchApi; + final String channelName; + + /// The scroll controller for handling the scroll to top button. + final scrollController = ScrollController(); + + /// The text controller for handling filtering the chatters. + final textController = TextEditingController(); + + /// The focus node for the textfield used for handling hiding/showing the cancel button. + final textFieldFocusNode = FocusNode(); + /// The rules and modes being used in the chat. @observable var roomState = const ROOMSTATE(); @@ -20,8 +31,10 @@ abstract class ChatDetailsStoreBase with Store { @observable var showJumpButton = false; - @observable - var filterText = ''; + /// The current text being used to filter the chatters. + /// Changing this will automatically update [filteredUsers]. + @readonly + var _filterText = ''; /// The list and types of chatters in the chat room. @readonly @@ -36,7 +49,7 @@ abstract class ChatDetailsStoreBase with Store { _chatUsers!.chatters.moderators, _chatUsers!.chatters.vips, _chatUsers!.chatters.viewers, - ].map((e) => e.where((user) => user.contains(filterText)).toList()); + ].map((e) => e.where((user) => user.contains(_filterText)).toList()); @computed List get allChatters => [ @@ -52,18 +65,36 @@ abstract class ChatDetailsStoreBase with Store { @readonly String? _error; - ChatDetailsStoreBase({required this.twitchApi}); + ChatDetailsStoreBase({required this.twitchApi, required this.channelName}) { + scrollController.addListener(() { + if (scrollController.position.atEdge || scrollController.position.outOfRange) { + showJumpButton = false; + } else { + showJumpButton = true; + } + }); + + textController.addListener(() => _filterText = textController.text); + + updateChatters(); + } @action - Future updateChatters(String userLogin) async { + Future updateChatters() async { try { - _chatUsers = await twitchApi.getChatters(userLogin: userLogin); + _chatUsers = await twitchApi.getChatters(userLogin: channelName); _error = null; } on SocketException { - _error = 'Failed to connect :('; + _error = 'Failed to connect'; } catch (e) { debugPrint(e.toString()); _error = e.toString(); } } + + void dispose() { + scrollController.dispose(); + textController.dispose(); + textFieldFocusNode.dispose(); + } } diff --git a/lib/screens/channel/stores/chat_details_store.g.dart b/lib/screens/channel/stores/chat_details_store.g.dart index dd6fa147..a5fb1827 100644 --- a/lib/screens/channel/stores/chat_details_store.g.dart +++ b/lib/screens/channel/stores/chat_details_store.g.dart @@ -56,19 +56,21 @@ mixin _$ChatDetailsStore on ChatDetailsStoreBase, Store { }); } - late final _$filterTextAtom = - Atom(name: 'ChatDetailsStoreBase.filterText', context: context); + late final _$_filterTextAtom = + Atom(name: 'ChatDetailsStoreBase._filterText', context: context); - @override String get filterText { - _$filterTextAtom.reportRead(); - return super.filterText; + _$_filterTextAtom.reportRead(); + return super._filterText; } @override - set filterText(String value) { - _$filterTextAtom.reportWrite(value, super.filterText, () { - super.filterText = value; + String get _filterText => filterText; + + @override + set _filterText(String value) { + _$_filterTextAtom.reportWrite(value, super._filterText, () { + super._filterText = value; }); } @@ -112,9 +114,8 @@ mixin _$ChatDetailsStore on ChatDetailsStoreBase, Store { AsyncAction('ChatDetailsStoreBase.updateChatters', context: context); @override - Future updateChatters(String userLogin) { - return _$updateChattersAsyncAction - .run(() => super.updateChatters(userLogin)); + Future updateChatters() { + return _$updateChattersAsyncAction.run(() => super.updateChatters()); } @override @@ -122,7 +123,6 @@ mixin _$ChatDetailsStore on ChatDetailsStoreBase, Store { return ''' roomState: ${roomState}, showJumpButton: ${showJumpButton}, -filterText: ${filterText}, filteredUsers: ${filteredUsers}, allChatters: ${allChatters} '''; diff --git a/lib/screens/channel/stores/chat_store.dart b/lib/screens/channel/stores/chat_store.dart index 1a57128f..b8d1d4e9 100644 --- a/lib/screens/channel/stores/chat_store.dart +++ b/lib/screens/channel/stores/chat_store.dart @@ -71,6 +71,9 @@ abstract class ChatStoreBase with Store { @readonly var _autoScroll = true; + @readonly + var _showSendButton = false; + @readonly var _showEmoteAutocomplete = false; @@ -103,7 +106,7 @@ abstract class ChatStoreBase with Store { )); assetsStore.init(); - chatDetailsStore.updateChatters(channelName); + chatDetailsStore.updateChatters(); _messages.add(IRCMessage.createNotice(message: 'Connecting to chat...')); @@ -131,7 +134,7 @@ abstract class ChatStoreBase with Store { if (assetsStore.showEmoteMenu) assetsStore.showEmoteMenu = false; // Refresh the chatters list to keep autocomplete mentions updated. - if (settings.autocomplete) chatDetailsStore.updateChatters(channelName); + if (settings.autocomplete) chatDetailsStore.updateChatters(); } // Un-expand the chat when unfocusing. @@ -140,10 +143,13 @@ 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(() => _showMentionAutocomplete = textFieldFocusNode.hasFocus && textController.text.split(' ').last.startsWith('@')); + textController.addListener(() { + _showSendButton = textController.text.isNotEmpty; + _showMentionAutocomplete = textFieldFocusNode.hasFocus && textController.text.split(' ').last.startsWith('@'); + }); } /// Handle and process the provided string-representation of the IRC data. @@ -223,8 +229,7 @@ abstract class ChatStoreBase with Store { _channel?.sink.add('PONG :tmi.twitch.tv'); return; } else if (message.contains('Welcome, GLHF!')) { - _messages - .add(IRCMessage.createNotice(message: "Connected to $displayName${regexEnglish.hasMatch(displayName) ? '' : ' ($channelName)'}'s chat!")); + _messages.add(IRCMessage.createNotice(message: "Connected to $displayName${regexEnglish.hasMatch(displayName) ? '' : ' ($channelName)'}'s chat!")); // Fetch the assets used in chat including badges and emotes. assetsStore.assetsFuture( @@ -383,6 +388,8 @@ abstract class ChatStoreBase with Store { textFieldFocusNode.dispose(); textController.dispose(); scrollController.dispose(); + assetsStore.dispose(); + chatDetailsStore.dispose(); } } diff --git a/lib/screens/channel/stores/chat_store.g.dart b/lib/screens/channel/stores/chat_store.g.dart index 4d4189c8..a2f3f2b1 100644 --- a/lib/screens/channel/stores/chat_store.g.dart +++ b/lib/screens/channel/stores/chat_store.g.dart @@ -45,6 +45,24 @@ mixin _$ChatStore on ChatStoreBase, Store { }); } + late final _$_showSendButtonAtom = + Atom(name: 'ChatStoreBase._showSendButton', context: context); + + bool get showSendButton { + _$_showSendButtonAtom.reportRead(); + return super._showSendButton; + } + + @override + bool get _showSendButton => showSendButton; + + @override + set _showSendButton(bool value) { + _$_showSendButtonAtom.reportWrite(value, super._showSendButton, () { + super._showSendButton = value; + }); + } + late final _$_showEmoteAutocompleteAtom = Atom(name: 'ChatStoreBase._showEmoteAutocomplete', context: context); diff --git a/lib/screens/channel/stores/video_store.dart b/lib/screens/channel/stores/video_store.dart index fadda1f7..fb093333 100644 --- a/lib/screens/channel/stores/video_store.dart +++ b/lib/screens/channel/stores/video_store.dart @@ -132,6 +132,8 @@ abstract class VideoStoreBase with Store { @action Future handleToggleOverlay() async { if (settingsStore.toggleableOverlay) { + HapticFeedback.mediumImpact(); + settingsStore.showOverlay = !settingsStore.showOverlay; await controller?.loadUrl(videoUrl); diff --git a/lib/screens/channel/video_chat.dart b/lib/screens/channel/video_chat.dart index daeff269..ce356aa3 100644 --- a/lib/screens/channel/video_chat.dart +++ b/lib/screens/channel/video_chat.dart @@ -18,6 +18,9 @@ import 'package:frosty/screens/channel/stores/chat_store.dart'; import 'package:frosty/screens/channel/stores/video_store.dart'; import 'package:frosty/screens/settings/settings.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; +import 'package:frosty/widgets/button.dart'; +import 'package:frosty/widgets/dialog.dart'; +import 'package:frosty/widgets/modal.dart'; import 'package:frosty/widgets/profile_picture.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; @@ -52,6 +55,7 @@ class _VideoChatState extends State { settings: context.read(), chatDetailsStore: ChatDetailsStore( twitchApi: context.read(), + channelName: widget.userLogin, ), assetsStore: ChatAssetsStore( twitchApi: context.read(), @@ -142,11 +146,14 @@ class _VideoChatState extends State { tooltip: 'Settings', icon: const Icon(Icons.settings), onPressed: () => showModalBottomSheet( + backgroundColor: Colors.transparent, isScrollControlled: true, context: context, - builder: (context) => SizedBox( - height: MediaQuery.of(context).size.height * 0.8, - child: Settings(settingsStore: settingsStore), + builder: (context) => FrostyModal( + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.8, + child: Settings(settingsStore: settingsStore), + ), ), ), ), @@ -168,9 +175,7 @@ class _VideoChatState extends State { builder: (context) { 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, + 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) @@ -208,9 +213,8 @@ class _VideoChatState extends State { ], ) : Row( - children: settingsStore.landscapeChatLeftSide - ? [landscapeChat, Expanded(child: video)] - : [Expanded(child: video), landscapeChat], + children: + settingsStore.landscapeChatLeftSide ? [landscapeChat, Expanded(child: video)] : [Expanded(child: video), landscapeChat], ) : Column( children: [appBar, Expanded(child: chat)], @@ -295,12 +299,24 @@ class _VideoOverlay extends StatelessWidget { Future _showSleepTimerDialog(BuildContext context) { return showDialog( context: context, - builder: (context) => AlertDialog( - title: const Text('Sleep Timer'), + 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( @@ -325,30 +341,12 @@ class _VideoOverlay extends StatelessWidget { const Text('Minutes'), ], ), - if (videoStore.sleepTimer != null && videoStore.sleepTimer!.isActive) - Row( - children: [ - const Icon(Icons.timer), - Text(' ${videoStore.timeRemaining.toString().split('.')[0]}'), - const Spacer(), - IconButton( - tooltip: 'Cancel Timer', - onPressed: videoStore.cancelSleepTimer, - icon: const Icon(Icons.cancel), - ), - ], - ), ], ), ), actions: [ - TextButton( - onPressed: Navigator.of(context).pop, - style: TextButton.styleFrom(primary: Colors.red), - child: const Text('Dismiss'), - ), Observer( - builder: (context) => ElevatedButton( + builder: (context) => Button( onPressed: videoStore.sleepHours == 0 && videoStore.sleepMinutes == 0 ? null : () => videoStore.updateSleepTimer( @@ -357,6 +355,12 @@ class _VideoOverlay extends StatelessWidget { child: const Text('Set Timer'), ), ), + Button( + onPressed: Navigator.of(context).pop, + fill: true, + color: Colors.red.shade700, + child: const Text('Close'), + ), ], ), ); @@ -385,18 +389,21 @@ class _VideoOverlay extends StatelessWidget { color: Colors.white, ), onPressed: () => showModalBottomSheet( + backgroundColor: Colors.transparent, isScrollControlled: true, context: context, - builder: (context) => SizedBox( - height: MediaQuery.of(context).size.height * 0.8, - child: Settings(settingsStore: videoStore.settingsStore), + builder: (context) => FrostyModal( + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.8, + child: Settings(settingsStore: videoStore.settingsStore), + ), ), ), ); final chatOverlayButton = Observer( builder: (_) => IconButton( - tooltip: videoStore.settingsStore.fullScreenChatOverlay ? 'Hide Chat Overlay' : 'Show Chat Overlay', + 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), color: Colors.white, @@ -413,7 +420,7 @@ class _VideoOverlay extends StatelessWidget { ); final fullScreenButton = IconButton( - tooltip: videoStore.settingsStore.fullScreen ? 'Exit Fullscreen' : 'Enter Fullscreen', + tooltip: videoStore.settingsStore.fullScreen ? 'Exit fullscreen mode' : 'Enter fullscreen mode', icon: videoStore.settingsStore.fullScreen ? const Icon( Icons.fullscreen_exit, @@ -427,7 +434,7 @@ class _VideoOverlay extends StatelessWidget { ); final sleepTimerButton = IconButton( - tooltip: 'Sleep Timer', + tooltip: 'Sleep timer', icon: const Icon( Icons.timer, color: Colors.white, @@ -436,7 +443,7 @@ class _VideoOverlay extends StatelessWidget { ); final rotateButton = IconButton( - tooltip: orientation == Orientation.portrait ? 'Enter Landscape Mode' : 'Exit Landscape Mode', + tooltip: orientation == Orientation.portrait ? 'Enter landscape mode' : 'Exit landscape mode', icon: const Icon( Icons.screen_rotation, color: Colors.white, @@ -609,7 +616,7 @@ class _VideoOverlay extends StatelessWidget { ), if (Platform.isIOS && videoStore.settingsStore.pictureInPicture) IconButton( - tooltip: 'Picture-in-Picture', + tooltip: 'Picture-in-picture', icon: const Icon( Icons.picture_in_picture_alt_rounded, color: Colors.white, diff --git a/lib/screens/home/home.dart b/lib/screens/home/home.dart index 5c74fcb3..4a8ebde3 100644 --- a/lib/screens/home/home.dart +++ b/lib/screens/home/home.dart @@ -8,6 +8,8 @@ import 'package:frosty/screens/home/top/top_section.dart'; import 'package:frosty/screens/home/widgets/streams_list.dart'; import 'package:frosty/screens/settings/settings.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; +import 'package:frosty/widgets/button.dart'; +import 'package:frosty/widgets/dialog.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -33,14 +35,8 @@ class _HomeState extends State { return showDialog( barrierDismissible: false, context: context, - builder: (context) => AlertDialog( - scrollable: true, - title: Text( - 'Frosty v${packageInfo.version} (${packageInfo.buildNumber})', - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - ), + builder: (context) => FrostyDialog( + title: 'Frosty v${packageInfo.version} (${packageInfo.buildNumber})', content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -56,7 +52,7 @@ class _HomeState extends State { ], ), actions: [ - TextButton( + Button( onPressed: () { prefs.setBool('first_run', false); @@ -107,32 +103,53 @@ class _HomeState extends State { ], ), body: SafeArea( - child: Observer( - builder: (_) => IndexedStack( - index: _homeStore.selectedIndex, - children: [ - if (_authStore.isLoggedIn) const StreamsList(listType: ListType.followed), - const TopSection(), - const Search(), - ], - ), + 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, + ), + ], + ), + ), + ), + const Divider(height: 1.0, thickness: 1.0), + ], ), ), bottomNavigationBar: Observer( builder: (_) => BottomNavigationBar( + unselectedFontSize: 12.0, + selectedFontSize: 12.0, + type: BottomNavigationBarType.fixed, items: [ if (_authStore.isLoggedIn) const BottomNavigationBarItem( icon: Icon(Icons.favorite), label: 'Followed', + tooltip: 'Followed streams', ), const BottomNavigationBarItem( icon: Icon(Icons.arrow_upward), label: 'Top', + tooltip: 'Top streams and categories', ), const BottomNavigationBarItem( icon: Icon(Icons.search), label: 'Search', + tooltip: 'Search for channels and categories', ), ], currentIndex: _homeStore.selectedIndex, diff --git a/lib/screens/home/search/search.dart b/lib/screens/home/search/search.dart index df78ee82..e5b073c2 100644 --- a/lib/screens/home/search/search.dart +++ b/lib/screens/home/search/search.dart @@ -6,11 +6,19 @@ import 'package:frosty/core/auth/auth_store.dart'; import 'package:frosty/screens/home/search/search_results_categories.dart'; import 'package:frosty/screens/home/search/search_results_channels.dart'; import 'package:frosty/screens/home/stores/search_store.dart'; +import 'package:frosty/widgets/alert_message.dart'; import 'package:frosty/widgets/section_header.dart'; import 'package:provider/provider.dart'; +/// The search section that contians search history and search results for channels and categories. class Search extends StatefulWidget { - const Search({Key? key}) : super(key: key); + // The scroll controller for handling scroll to top functionality. + final ScrollController scrollController; + + const Search({ + Key? key, + required this.scrollController, + }) : super(key: key); @override State createState() => _SearchState(); @@ -22,8 +30,6 @@ class _SearchState extends State { twitchApi: context.read(), ); - final _textEditingController = TextEditingController(); - @override Widget build(BuildContext context) { const headerPadding = EdgeInsets.fromLTRB(15.0, 20.0, 15.0, 10.0); @@ -32,45 +38,53 @@ class _SearchState extends State { children: [ Padding( padding: const EdgeInsets.symmetric(vertical: 5.0, horizontal: 10.0), - child: TextField( - controller: _textEditingController, - autocorrect: false, - decoration: InputDecoration( - isDense: true, - labelText: 'Find a channel or category', - contentPadding: const EdgeInsets.all(10.0), - border: const OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(15.0)), - ), - suffixIcon: IconButton( - tooltip: 'Clear Search', - onPressed: () { - FocusScope.of(context).unfocus(); - setState(() { - _textEditingController.clear(); - }); - }, - icon: const Icon(Icons.clear), - ), - ), - onSubmitted: _searchStore.handleQuery, + child: Observer( + builder: (context) { + return TextField( + controller: _searchStore.textEditingController, + focusNode: _searchStore.textFieldFocusNode, + autocorrect: false, + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(0.0), + hintText: 'Find a channel or category', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchStore.textFieldFocusNode.hasFocus || _searchStore.searchText.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + tooltip: _searchStore.searchText.isEmpty ? 'Cancel' : 'Clear', + onPressed: () { + if (_searchStore.searchText.isEmpty) _searchStore.textFieldFocusNode.unfocus(); + _searchStore.textEditingController.clear(); + }, + ) + : null, + ), + onSubmitted: _searchStore.handleQuery, + ); + }, ), ), Expanded( child: Observer( builder: (context) { - if (_textEditingController.text.isEmpty || _searchStore.channelFuture == null || _searchStore.categoryFuture == null) { + if (_searchStore.textEditingController.text.isEmpty) { + if (_searchStore.searchHistory.isEmpty) { + return const AlertMessage( + message: 'No recent searches', + icon: Icons.search_off, + ); + } + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (_searchStore.searchHistory.isNotEmpty) - const SectionHeader( - 'History', - fontSize: 12.0, - padding: headerPadding, - ), + const SectionHeader( + 'History', + padding: headerPadding, + ), Expanded( child: ListView( + controller: widget.scrollController, children: _searchStore.searchHistory .mapIndexed( (index, searchTerm) => ListTile( @@ -79,15 +93,13 @@ class _SearchState extends State { trailing: IconButton( tooltip: 'Remove', icon: const Icon(Icons.cancel), - onPressed: () => setState(() { - _searchStore.searchHistory.removeAt(index); - }), + onPressed: () => _searchStore.searchHistory.removeAt(index), ), onTap: () { - setState(() { - _textEditingController.text = searchTerm; - _searchStore.handleQuery(searchTerm); - }); + _searchStore.textEditingController.text = searchTerm; + _searchStore.handleQuery(searchTerm); + _searchStore.textEditingController.selection = + TextSelection.fromPosition(TextPosition(offset: _searchStore.textEditingController.text.length)); }, ), ) @@ -98,22 +110,21 @@ class _SearchState extends State { ); } return CustomScrollView( + controller: widget.scrollController, slivers: [ const SliverToBoxAdapter( child: SectionHeader( 'Channels', - fontSize: 12.0, padding: headerPadding, ), ), SearchResultsChannels( searchStore: _searchStore, - query: _textEditingController.text, + query: _searchStore.searchText, ), const SliverToBoxAdapter( child: SectionHeader( 'Categories', - fontSize: 12.0, padding: headerPadding, ), ), @@ -129,7 +140,7 @@ class _SearchState extends State { @override void dispose() { - _textEditingController.dispose(); + _searchStore.dispose(); super.dispose(); } } diff --git a/lib/screens/home/search/search_results_categories.dart b/lib/screens/home/search/search_results_categories.dart index 53de88f0..68bfe44d 100644 --- a/lib/screens/home/search/search_results_categories.dart +++ b/lib/screens/home/search/search_results_categories.dart @@ -3,6 +3,7 @@ import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/models/category.dart'; import 'package:frosty/screens/home/stores/search_store.dart'; import 'package:frosty/screens/home/widgets/category_card.dart'; +import 'package:frosty/widgets/alert_message.dart'; import 'package:frosty/widgets/loading_indicator.dart'; import 'package:mobx/mobx.dart'; @@ -13,12 +14,6 @@ class SearchResultsCategories extends StatelessWidget { @override Widget build(BuildContext context) { - final size = MediaQuery.of(context).size; - final pixelRatio = MediaQuery.of(context).devicePixelRatio; - - final artWidth = (size.width * pixelRatio) ~/ 3; - final artHeight = (artWidth * (4 / 3)).toInt(); - return Observer( builder: (context) { final future = searchStore.categoryFuture; @@ -26,14 +21,21 @@ class SearchResultsCategories extends StatelessWidget { switch (future!.status) { case FutureStatus.pending: return const SliverToBoxAdapter( - child: LoadingIndicator( - subtitle: Text('Loading categories...'), + child: SizedBox( + height: 100.0, + child: LoadingIndicator( + subtitle: 'Loading categories...', + ), ), ); case FutureStatus.rejected: return const SliverToBoxAdapter( - child: Center( - child: Text('Failed to get categories'), + child: SizedBox( + height: 100.0, + child: AlertMessage( + message: 'Failed to get categories', + icon: Icons.error, + ), ), ); case FutureStatus.fulfilled: @@ -41,8 +43,12 @@ class SearchResultsCategories extends StatelessWidget { if (categories == null) { return const SliverToBoxAdapter( - child: Center( - child: Text('Failed to get categories'), + child: SizedBox( + height: 100.0, + child: AlertMessage( + message: 'Failed to get categories', + icon: Icons.error, + ), ), ); } @@ -51,7 +57,7 @@ class SearchResultsCategories extends StatelessWidget { return const SliverToBoxAdapter( child: SizedBox( height: 100.0, - child: Center(child: Text('No matching categories')), + child: AlertMessage(message: 'No matching categories'), ), ); } @@ -64,8 +70,6 @@ class SearchResultsCategories extends StatelessWidget { (category) => GridTile( child: CategoryCard( category: category, - width: artWidth, - height: artHeight, ), ), ) diff --git a/lib/screens/home/search/search_results_channels.dart b/lib/screens/home/search/search_results_channels.dart index 63a44546..87f0cff4 100644 --- a/lib/screens/home/search/search_results_channels.dart +++ b/lib/screens/home/search/search_results_channels.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/constants/constants.dart'; import 'package:frosty/models/channel.dart'; import 'package:frosty/screens/channel/video_chat.dart'; import 'package:frosty/screens/home/stores/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/loading_indicator.dart'; import 'package:frosty/widgets/profile_picture.dart'; @@ -39,11 +42,15 @@ class _SearchResultsChannelsState extends State { ), ), ); - } catch (e) { + } catch (error) { final snackBar = SnackBar( - content: Text(e.toString()), + content: AlertMessage( + message: error.toString(), + icon: Icons.error, + ), behavior: SnackBarBehavior.floating, ); + ScaffoldMessenger.of(context).showSnackBar(snackBar); } } @@ -58,13 +65,17 @@ class _SearchResultsChannelsState extends State { case FutureStatus.pending: return const SliverToBoxAdapter( child: LoadingIndicator( - subtitle: Text('Loading channels...'), + subtitle: 'Loading channels...', ), ); case FutureStatus.rejected: return const SliverToBoxAdapter( - child: Center( - child: Text('Failed to get channels'), + child: SizedBox( + height: 100.0, + child: AlertMessage( + message: 'Failed to get channels', + icon: Icons.error, + ), ), ); case FutureStatus.fulfilled: @@ -78,28 +89,7 @@ class _SearchResultsChannelsState extends State { final displayName = regexEnglish.hasMatch(channel.displayName) ? channel.displayName : '${channel.displayName} (${channel.broadcasterLogin})'; - return 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, - ), - ), - ), - ) - : null, - subtitle: channel.isLive - ? Text('Uptime: ${DateTime.now().difference(DateTime.parse(channel.startedAt)).toString().split('.')[0]}') - : null, + return AnimateScale( onTap: () => Navigator.push( context, MaterialPageRoute( @@ -110,14 +100,41 @@ class _SearchResultsChannelsState extends State { ), ), ), - onLongPress: () => showModalBottomSheet( - context: context, - builder: (context) => BlockReportModal( - authStore: widget.searchStore.authStore, - name: displayName, - userLogin: channel.broadcasterLogin, - userId: channel.id, - ), + 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, + ), + ), + ), + ) + : null, + subtitle: + channel.isLive ? Text('Uptime: ${DateTime.now().difference(DateTime.parse(channel.startedAt)).toString().split('.')[0]}') : null, ), ); }, diff --git a/lib/screens/home/stores/categories_store.dart b/lib/screens/home/stores/categories_store.dart index 7767e24a..734a57a2 100644 --- a/lib/screens/home/stores/categories_store.dart +++ b/lib/screens/home/stores/categories_store.dart @@ -1,6 +1,5 @@ import 'dart:io'; -import 'package:flutter/material.dart'; import 'package:frosty/api/twitch_api.dart'; import 'package:frosty/core/auth/auth_store.dart'; import 'package:frosty/models/category.dart'; @@ -21,18 +20,8 @@ abstract class CategoriesStoreBase with Store { String? _categoriesCursor; /// The loading status for pagination. + @readonly bool _isLoading = false; - bool get isLoading => _isLoading; - - /// Returns whether or not there are more streams and loading status for pagination. - bool get hasMore => _isLoading == false && _categoriesCursor != null; - - /// The scroll controller used for handling scroll to top. - final scrollController = ScrollController(); - - /// Whether or not the scroll to top button is visible. - @observable - var showJumpButton = false; /// The current visible categories, sorted by total viewers. @readonly @@ -42,20 +31,15 @@ abstract class CategoriesStoreBase with Store { @readonly String? _error; - CategoriesStoreBase({required this.authStore, required this.twitchApi}) { - scrollController.addListener(() { - if (scrollController.position.atEdge || scrollController.position.outOfRange) { - showJumpButton = false; - } else { - showJumpButton = true; - } - }); + /// Returns whether or not there are more streams and loading status for pagination. + @computed + bool get hasMore => _isLoading == false && _categoriesCursor != null; + CategoriesStoreBase({required this.authStore, required this.twitchApi}) { getCategories(); } // Fetches the top categories based on the current cursor. - @action Future getCategories() async { _isLoading = true; @@ -64,6 +48,7 @@ abstract class CategoriesStoreBase with Store { headers: authStore.headersTwitch, cursor: _categoriesCursor, ); + if (_categoriesCursor == null) { _categories = result.data.asObservable(); } else { @@ -73,7 +58,7 @@ abstract class CategoriesStoreBase with Store { _error = null; } on SocketException { - _error = 'Failed to connect :('; + _error = 'Failed to connect'; } catch (e) { _error = e.toString(); } @@ -88,8 +73,4 @@ abstract class CategoriesStoreBase with Store { return getCategories(); } - - void dispose() { - scrollController.dispose(); - } } diff --git a/lib/screens/home/stores/categories_store.g.dart b/lib/screens/home/stores/categories_store.g.dart index ef0c9bd6..f54a3dc4 100644 --- a/lib/screens/home/stores/categories_store.g.dart +++ b/lib/screens/home/stores/categories_store.g.dart @@ -9,19 +9,28 @@ part of 'categories_store.dart'; // ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers mixin _$CategoriesStore on CategoriesStoreBase, Store { - late final _$showJumpButtonAtom = - Atom(name: 'CategoriesStoreBase.showJumpButton', context: context); + Computed? _$hasMoreComputed; @override - bool get showJumpButton { - _$showJumpButtonAtom.reportRead(); - return super.showJumpButton; + bool get hasMore => (_$hasMoreComputed ??= Computed(() => super.hasMore, + name: 'CategoriesStoreBase.hasMore')) + .value; + + late final _$_isLoadingAtom = + Atom(name: 'CategoriesStoreBase._isLoading', context: context); + + bool get isLoading { + _$_isLoadingAtom.reportRead(); + return super._isLoading; } @override - set showJumpButton(bool value) { - _$showJumpButtonAtom.reportWrite(value, super.showJumpButton, () { - super.showJumpButton = value; + bool get _isLoading => isLoading; + + @override + set _isLoading(bool value) { + _$_isLoadingAtom.reportWrite(value, super._isLoading, () { + super._isLoading = value; }); } @@ -61,14 +70,6 @@ mixin _$CategoriesStore on CategoriesStoreBase, Store { }); } - late final _$getCategoriesAsyncAction = - AsyncAction('CategoriesStoreBase.getCategories', context: context); - - @override - Future getCategories() { - return _$getCategoriesAsyncAction.run(() => super.getCategories()); - } - late final _$CategoriesStoreBaseActionController = ActionController(name: 'CategoriesStoreBase', context: context); @@ -86,7 +87,7 @@ mixin _$CategoriesStore on CategoriesStoreBase, Store { @override String toString() { return ''' -showJumpButton: ${showJumpButton} +hasMore: ${hasMore} '''; } } diff --git a/lib/screens/home/stores/home_store.dart b/lib/screens/home/stores/home_store.dart index d3054a6b..16e1a4cc 100644 --- a/lib/screens/home/stores/home_store.dart +++ b/lib/screens/home/stores/home_store.dart @@ -1,31 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import 'package:frosty/core/auth/auth_store.dart'; import 'package:mobx/mobx.dart'; part 'home_store.g.dart'; +/// The home store that handles scrolling and navigating between tabs on the home screen. class HomeStore = HomeStoreBase with _$HomeStore; abstract class HomeStoreBase with Store { final AuthStore authStore; - late final ReactionDisposer _disposeReaction; + /// The scroll controller for controlling the scroll to top on the followed section. + final followedScrollController = ScrollController(); + /// The scroll controllers for controlling the scroll to top on the top section. + /// One controller is for the top streams tab and the other is for the top categories tab. + final topSectionScrollControllers = [ + ScrollController(), + ScrollController(), + ]; + + /// The scroll controller for controlling the scroll to top on the search section. + final searchScrollController = ScrollController(); + + /// The current index of the top section tab. Changes when switching between the streams and categories tabs. + var topSectionCurrentIndex = 0; + + /// The current selected index/tab of the bottom navigation bar. + @readonly + var _selectedIndex = 0; + + late final ReactionDisposer _disposeReaction; HomeStoreBase({required this.authStore}) { + // Setup a reaction that will return to the first tab when logging in/out. + // This will prevent an out of range index error when last visiting the search screen and logging out. _disposeReaction = reaction( (_) => authStore.isLoggedIn, (_) => _selectedIndex = 0, ); } - @readonly - var _selectedIndex = 0; - @action void handleTap(int index) { + // If tapping a different tab, set the new index to show. if (index != _selectedIndex) { _selectedIndex = index; + } else { + const duration = Duration(milliseconds: 500); + + // Use different logic if logged in/out since there will be one less tab when logged out. + if (authStore.isLoggedIn) { + if (index == 0) { + // If on the followed tab and tapping the followed tab, scroll to the top. + followedScrollController.animateTo( + 0.0, + duration: duration, + curve: Curves.easeOutCubic, + ); + } else if (index == 1) { + // 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, + ); + } else { + // If on the search tab and tapping the search tab, scroll to the top. + searchScrollController.animateTo( + 0.0, + duration: duration, + curve: Curves.easeOutCubic, + ); + } + } else { + if (index == 0) { + // 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, + ); + } else { + // If on the search tab and tapping the search tab, scroll to the top. + searchScrollController.animateTo( + 0.0, + duration: duration, + curve: Curves.easeOutCubic, + ); + } + } } } - void dispose() => _disposeReaction(); + void dispose() { + _disposeReaction(); + + followedScrollController.dispose(); + + for (final controller in topSectionScrollControllers) { + controller.dispose(); + } + } } diff --git a/lib/screens/home/stores/list_store.dart b/lib/screens/home/stores/list_store.dart index e8e0c361..c40003d9 100644 --- a/lib/screens/home/stores/list_store.dart +++ b/lib/screens/home/stores/list_store.dart @@ -3,7 +3,6 @@ import 'dart:io'; import 'package:flutter/widgets.dart'; import 'package:frosty/api/twitch_api.dart'; import 'package:frosty/core/auth/auth_store.dart'; -import 'package:frosty/models/category.dart'; import 'package:frosty/models/stream.dart'; import 'package:mobx/mobx.dart'; @@ -21,30 +20,33 @@ abstract class ListStoreBase with Store { /// The type of list that this store is handling. final ListType listType; - /// The category to use if the [listType] is category streams. - final CategoryTwitch? categoryInfo; + /// The category id to use when fetching streams if the [listType] is [ListType.category]. + final String? categoryId; + + /// The scroll controller used for handling scroll to top (if provided). + /// If provided, will use the [ScrollToTop] widget to scroll to the top of the list instead of the bottom tab bar. + final ScrollController? scrollController; /// The pagination cursor for the streams. String? _streamsCursor; /// Returns whether or not there are more streams and loading status for pagination. + @computed bool get hasMore => _isLoading == false && _streamsCursor != null; /// The loading status for pagination. + @readonly bool _isLoading = false; - bool get isLoading => _isLoading; - - /// The scroll controller used for handling scroll to top. - final scrollController = ScrollController(); - - /// Whether or not the scroll to top button is visible. - @observable - var showJumpButton = false; /// The list of the fetched streams. @readonly var _allStreams = ObservableList(); + /// Whether or not the scroll to top button is visible. + @observable + var showJumpButton = false; + + /// The list of the fetched streams with blocked users filtered out. @computed ObservableList get streams => _allStreams .where((streamInfo) => !authStore.user.blockedUsers.map((blockedUser) => blockedUser.userId).contains(streamInfo.userId)) @@ -59,15 +61,18 @@ abstract class ListStoreBase with Store { required this.authStore, required this.twitchApi, required this.listType, - this.categoryInfo, + this.categoryId, + this.scrollController, }) { - scrollController.addListener(() { - if (scrollController.position.atEdge || scrollController.position.outOfRange) { - showJumpButton = false; - } else { - showJumpButton = true; - } - }); + if (scrollController != null) { + scrollController!.addListener(() { + if (scrollController!.position.atEdge || scrollController!.position.outOfRange) { + showJumpButton = false; + } else { + showJumpButton = true; + } + }); + } switch (listType) { case ListType.followed: @@ -81,7 +86,6 @@ abstract class ListStoreBase with Store { } /// Fetches the streams based on the type and current cursor. - @action Future getStreams() async { _isLoading = true; @@ -103,7 +107,7 @@ abstract class ListStoreBase with Store { break; case ListType.category: newStreams = await twitchApi.getStreamsUnderCategory( - gameId: categoryInfo!.id, + gameId: categoryId!, headers: authStore.headersTwitch, cursor: _streamsCursor, ); @@ -119,7 +123,7 @@ abstract class ListStoreBase with Store { _error = null; } on SocketException { - _error = 'Failed to connect :('; + _error = 'Failed to connect'; } catch (e) { _error = e.toString(); } @@ -135,9 +139,14 @@ abstract class ListStoreBase with Store { return getStreams(); } - void dispose() => scrollController.dispose(); + void dispose() => scrollController?.dispose(); } +/// The possible types of lists that can be displayed. +/// +/// [ListType.followed] is the list of streams that the user is following. +/// [ListType.top] is the list of top streams. +/// [ListType.category] is the list of streams under a category. enum ListType { followed, top, diff --git a/lib/screens/home/stores/list_store.g.dart b/lib/screens/home/stores/list_store.g.dart index 7275b890..c4e0db7e 100644 --- a/lib/screens/home/stores/list_store.g.dart +++ b/lib/screens/home/stores/list_store.g.dart @@ -9,6 +9,12 @@ part of 'list_store.dart'; // ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers mixin _$ListStore on ListStoreBase, Store { + Computed? _$hasMoreComputed; + + @override + bool get hasMore => (_$hasMoreComputed ??= + Computed(() => super.hasMore, name: 'ListStoreBase.hasMore')) + .value; Computed>? _$streamsComputed; @override @@ -17,19 +23,21 @@ mixin _$ListStore on ListStoreBase, Store { name: 'ListStoreBase.streams')) .value; - late final _$showJumpButtonAtom = - Atom(name: 'ListStoreBase.showJumpButton', context: context); + late final _$_isLoadingAtom = + Atom(name: 'ListStoreBase._isLoading', context: context); - @override - bool get showJumpButton { - _$showJumpButtonAtom.reportRead(); - return super.showJumpButton; + bool get isLoading { + _$_isLoadingAtom.reportRead(); + return super._isLoading; } @override - set showJumpButton(bool value) { - _$showJumpButtonAtom.reportWrite(value, super.showJumpButton, () { - super.showJumpButton = value; + bool get _isLoading => isLoading; + + @override + set _isLoading(bool value) { + _$_isLoadingAtom.reportWrite(value, super._isLoading, () { + super._isLoading = value; }); } @@ -51,6 +59,22 @@ mixin _$ListStore on ListStoreBase, Store { }); } + late final _$showJumpButtonAtom = + Atom(name: 'ListStoreBase.showJumpButton', context: context); + + @override + bool get showJumpButton { + _$showJumpButtonAtom.reportRead(); + return super.showJumpButton; + } + + @override + set showJumpButton(bool value) { + _$showJumpButtonAtom.reportWrite(value, super.showJumpButton, () { + super.showJumpButton = value; + }); + } + late final _$_errorAtom = Atom(name: 'ListStoreBase._error', context: context); @@ -69,14 +93,6 @@ mixin _$ListStore on ListStoreBase, Store { }); } - late final _$getStreamsAsyncAction = - AsyncAction('ListStoreBase.getStreams', context: context); - - @override - Future getStreams() { - return _$getStreamsAsyncAction.run(() => super.getStreams()); - } - late final _$ListStoreBaseActionController = ActionController(name: 'ListStoreBase', context: context); @@ -95,6 +111,7 @@ mixin _$ListStore on ListStoreBase, Store { String toString() { return ''' showJumpButton: ${showJumpButton}, +hasMore: ${hasMore}, streams: ${streams} '''; } diff --git a/lib/screens/home/stores/search_store.dart b/lib/screens/home/stores/search_store.dart index f5a3a9ff..5856d27f 100644 --- a/lib/screens/home/stores/search_store.dart +++ b/lib/screens/home/stores/search_store.dart @@ -1,3 +1,4 @@ +import 'package:flutter/widgets.dart'; import 'package:frosty/api/twitch_api.dart'; import 'package:frosty/core/auth/auth_store.dart'; import 'package:frosty/models/category.dart'; @@ -14,6 +15,13 @@ abstract class SearchStoreBase with Store { final TwitchApi twitchApi; + final textEditingController = TextEditingController(); + + final textFieldFocusNode = FocusNode(); + + @readonly + var _searchText = ''; + @readonly var _searchHistory = ObservableList(); @@ -32,21 +40,29 @@ abstract class SearchStoreBase with Store { // Retrieve the instance that will allow us to retrieve local search history. final prefs = await SharedPreferences.getInstance(); + // Retrieve the search history from local storage. If it doesn't exist, use an empty list. _searchHistory = prefs.getStringList('search_history')?.asObservable() ?? ObservableList(); + // Create a reaction that will limit the history to 8 entries and update it to local storage automatically. autorun((_) { if (_searchHistory.length > 8) _searchHistory.removeLast(); prefs.setStringList('search_history', _searchHistory); }); + + // Add a listener to update the cancel button visibility whenever the text changes. + textEditingController.addListener(() => _searchText = textEditingController.text); } + /// Obtain the channels and categories that match the provided [query]. @action - Future handleQuery(String query) async { + void handleQuery(String query) { if (query.isEmpty) return; + // Move the query to the most recent result (top of the stack). _searchHistory.remove(query); _searchHistory.insert(0, query); + // Fetch the matching channels, sort it by live status, and then set it. _channelFuture = twitchApi .searchChannels( query: query, @@ -59,6 +75,7 @@ abstract class SearchStoreBase with Store { }, ).asObservable(); + // Fetch and set the categories that match the query. _categoryFuture = twitchApi .searchCategories( query: query, @@ -67,8 +84,15 @@ abstract class SearchStoreBase with Store { .asObservable(); } + /// Find a specific channel provided the [query]. + /// This is used for channels that may not show up in the search results. Future searchChannel(String query) async { final user = await twitchApi.getUser(userLogin: query, headers: authStore.headersTwitch); return await twitchApi.getChannel(userId: user.id, headers: authStore.headersTwitch); } + + void dispose() { + textEditingController.dispose(); + textFieldFocusNode.dispose(); + } } diff --git a/lib/screens/home/stores/search_store.g.dart b/lib/screens/home/stores/search_store.g.dart index 4a239c56..f114d0a1 100644 --- a/lib/screens/home/stores/search_store.g.dart +++ b/lib/screens/home/stores/search_store.g.dart @@ -9,6 +9,24 @@ part of 'search_store.dart'; // ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers mixin _$SearchStore on SearchStoreBase, Store { + late final _$_searchTextAtom = + Atom(name: 'SearchStoreBase._searchText', context: context); + + String get searchText { + _$_searchTextAtom.reportRead(); + return super._searchText; + } + + @override + String get _searchText => searchText; + + @override + set _searchText(String value) { + _$_searchTextAtom.reportWrite(value, super._searchText, () { + super._searchText = value; + }); + } + late final _$_searchHistoryAtom = Atom(name: 'SearchStoreBase._searchHistory', context: context); @@ -71,12 +89,18 @@ mixin _$SearchStore on SearchStoreBase, Store { return _$initAsyncAction.run(() => super.init()); } - late final _$handleQueryAsyncAction = - AsyncAction('SearchStoreBase.handleQuery', context: context); + late final _$SearchStoreBaseActionController = + ActionController(name: 'SearchStoreBase', context: context); @override - Future handleQuery(String query) { - return _$handleQueryAsyncAction.run(() => super.handleQuery(query)); + void handleQuery(String query) { + final _$actionInfo = _$SearchStoreBaseActionController.startAction( + name: 'SearchStoreBase.handleQuery'); + try { + return super.handleQuery(query); + } finally { + _$SearchStoreBaseActionController.endAction(_$actionInfo); + } } @override diff --git a/lib/screens/home/top/categories.dart b/lib/screens/home/top/categories.dart index ceb5c549..9aea1c25 100644 --- a/lib/screens/home/top/categories.dart +++ b/lib/screens/home/top/categories.dart @@ -5,12 +5,17 @@ import 'package:frosty/api/twitch_api.dart'; import 'package:frosty/core/auth/auth_store.dart'; import 'package:frosty/screens/home/stores/categories_store.dart'; import 'package:frosty/screens/home/widgets/category_card.dart'; +import 'package:frosty/widgets/alert_message.dart'; import 'package:frosty/widgets/loading_indicator.dart'; -import 'package:frosty/widgets/scroll_to_top_button.dart'; import 'package:provider/provider.dart'; class Categories extends StatefulWidget { - const Categories({Key? key}) : super(key: key); + final ScrollController scrollController; + + const Categories({ + Key? key, + required this.scrollController, + }) : super(key: key); @override State createState() => _CategoriesState(); @@ -26,12 +31,6 @@ class _CategoriesState extends State with AutomaticKeepAliveClientMi Widget build(BuildContext context) { super.build(context); - final size = MediaQuery.of(context).size; - final pixelRatio = MediaQuery.of(context).devicePixelRatio; - - final artWidth = (size.width * pixelRatio) ~/ 3; - final artHeight = (artWidth * (4 / 3)).toInt(); - return RefreshIndicator( onRefresh: () async { HapticFeedback.lightImpact(); @@ -39,7 +38,10 @@ class _CategoriesState extends State with AutomaticKeepAliveClientMi if (_categoriesStore.error != null) { final snackBar = SnackBar( - content: Text(_categoriesStore.error!), + content: AlertMessage( + message: _categoriesStore.error!, + icon: Icons.error, + ), behavior: SnackBarBehavior.floating, ); @@ -49,37 +51,50 @@ class _CategoriesState extends State with AutomaticKeepAliveClientMi }, child: Observer( builder: (_) { - if (_categoriesStore.categories.isEmpty && _categoriesStore.isLoading && _categoriesStore.error == null) { - return const LoadingIndicator(subtitle: Text('Loading categories...')); + Widget? statusWidget; + + if (_categoriesStore.error != null) { + statusWidget = AlertMessage( + message: _categoriesStore.error!, + icon: Icons.error, + ); } - return Stack( - alignment: AlignmentDirectional.bottomCenter, - children: [ - GridView.builder( - physics: const AlwaysScrollableScrollPhysics(), - controller: _categoriesStore.scrollController, - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - ), - itemCount: _categoriesStore.categories.length, - itemBuilder: (context, index) { - if (index > _categoriesStore.categories.length / 2 && _categoriesStore.hasMore) { - _categoriesStore.getCategories(); - } - return CategoryCard( - category: _categoriesStore.categories[index], - width: artWidth, - height: artHeight, - ); - }, - ), - Observer( - builder: (context) => AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - child: _categoriesStore.showJumpButton ? ScrollToTopButton(scrollController: _categoriesStore.scrollController) : null, - ), - ), - ], + + if (_categoriesStore.categories.isEmpty) { + if (_categoriesStore.isLoading && _categoriesStore.error == null) { + statusWidget = const LoadingIndicator(subtitle: 'Loading categories...'); + } else { + statusWidget = const AlertMessage(message: 'No top categories'); + } + } + + if (statusWidget != null) { + return CustomScrollView( + slivers: [ + SliverFillRemaining( + child: Center( + child: statusWidget, + ), + ) + ], + ); + } + + return GridView.builder( + physics: const AlwaysScrollableScrollPhysics(), + controller: widget.scrollController, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + ), + itemCount: _categoriesStore.categories.length, + itemBuilder: (context, index) { + if (index > _categoriesStore.categories.length - 8 && _categoriesStore.hasMore) { + _categoriesStore.getCategories(); + } + return CategoryCard( + category: _categoriesStore.categories[index], + ); + }, ); }, ), @@ -88,10 +103,4 @@ class _CategoriesState extends State with AutomaticKeepAliveClientMi @override bool get wantKeepAlive => true; - - @override - void dispose() { - _categoriesStore.dispose(); - super.dispose(); - } } diff --git a/lib/screens/home/top/category_streams.dart b/lib/screens/home/top/category_streams.dart index 7245b049..cb8cb0b7 100644 --- a/lib/screens/home/top/category_streams.dart +++ b/lib/screens/home/top/category_streams.dart @@ -2,43 +2,65 @@ 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/api/twitch_api.dart'; +import 'package:frosty/core/auth/auth_store.dart'; +import 'package:frosty/models/category.dart'; import 'package:frosty/screens/home/stores/list_store.dart'; import 'package:frosty/screens/home/widgets/stream_card.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; +import 'package:frosty/widgets/alert_message.dart'; import 'package:frosty/widgets/loading_indicator.dart'; import 'package:frosty/widgets/scroll_to_top_button.dart'; import 'package:provider/provider.dart'; +/// A widget that displays a list of streams under the provided [categoryId]. +/// This differs from the normal [StreamsList] in that it uses slivers to display the box art on top. class CategoryStreams extends StatefulWidget { - final ListStore listStore; + /// The category name, used for the header on the box art sliver. + final String categoryName; - const CategoryStreams({Key? key, required this.listStore}) : super(key: key); + /// The category id, used for fetching the relevant streams in the [ListStore]. + final String categoryId; + + const CategoryStreams({ + Key? key, + required this.categoryName, + required this.categoryId, + }) : super(key: key); @override State createState() => _CategoryStreamsState(); } class _CategoryStreamsState extends State { + late final _listStore = ListStore( + authStore: context.read(), + twitchApi: context.read(), + listType: ListType.category, + categoryId: widget.categoryId, + scrollController: ScrollController(), + ); + @override Widget build(BuildContext context) { + // Calculate the dimensions of the box art based on the screen width. final size = MediaQuery.of(context).size; final pixelRatio = MediaQuery.of(context).devicePixelRatio; - final artWidth = (size.width * pixelRatio).toInt(); final artHeight = (artWidth * (4 / 3)).toInt(); - final thumbnailWidth = (size.width * pixelRatio) ~/ 3; - final thumbnailHeight = (thumbnailWidth * (9 / 16)).toInt(); - return Scaffold( body: RefreshIndicator( onRefresh: () async { HapticFeedback.lightImpact(); - await widget.listStore.refreshStreams(); + await _listStore.refreshStreams(); - if (widget.listStore.error != null) { + if (_listStore.error != null) { final snackBar = SnackBar( - content: Text(widget.listStore.error!), + content: AlertMessage( + message: _listStore.error!, + icon: Icons.error, + ), behavior: SnackBarBehavior.floating, ); @@ -48,15 +70,12 @@ class _CategoryStreamsState extends State { }, child: Observer( builder: (context) { - if (widget.listStore.streams.isEmpty && widget.listStore.isLoading && widget.listStore.error == null) { - return const LoadingIndicator(subtitle: Text('Loading streams...')); - } return Stack( alignment: AlignmentDirectional.bottomCenter, children: [ CustomScrollView( physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()), - controller: widget.listStore.scrollController, + controller: _listStore.scrollController, slivers: [ SliverAppBar( stretch: true, @@ -69,55 +88,89 @@ class _CategoryStreamsState extends State { ], centerTitle: true, title: Text( - widget.listStore.categoryInfo!.name, + widget.categoryName, style: Theme.of(context).textTheme.headline6?.copyWith(fontWeight: FontWeight.bold), maxLines: 1, overflow: TextOverflow.ellipsis, ), - background: CachedNetworkImage( - imageUrl: widget.listStore.categoryInfo!.boxArtUrl.replaceRange( - widget.listStore.categoryInfo!.boxArtUrl.lastIndexOf('-') + 1, - null, - '${artWidth}x$artHeight.jpg', - ), - placeholder: (context, url) => const LoadingIndicator(), - color: const Color.fromRGBO(255, 255, 255, 0.5), - colorBlendMode: BlendMode.modulate, - fit: BoxFit.cover, - ), - ), - ), - SliverSafeArea( - top: false, - sliver: SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - if (index > widget.listStore.streams.length / 2 && widget.listStore.hasMore) { - widget.listStore.getStreams(); - } - return Observer( - builder: (context) => StreamCard( - listStore: widget.listStore, - streamInfo: widget.listStore.streams[index], - width: thumbnailWidth, - height: thumbnailHeight, - showUptime: context.read().showThumbnailUptime, - showThumbnail: context.read().showThumbnails, - showCategory: false, + background: FutureBuilder( + future: context.read().getCategory( + headers: context.read().headersTwitch, + gameId: _listStore.categoryId!, ), - ); + builder: (context, AsyncSnapshot snapshot) { + return snapshot.hasData + ? CachedNetworkImage( + imageUrl: snapshot.data!.data.first.boxArtUrl.replaceRange( + snapshot.data!.data.first.boxArtUrl.lastIndexOf('-') + 1, + null, + '${artWidth}x$artHeight.jpg', + ), + placeholder: (context, url) => const LoadingIndicator(), + color: const Color.fromRGBO(255, 255, 255, 0.5), + colorBlendMode: BlendMode.modulate, + fit: BoxFit.cover, + ) + : const SizedBox(); }, - childCount: widget.listStore.streams.length, ), ), ), + if (_listStore.error != null) + SliverFillRemaining( + hasScrollBody: false, + child: AlertMessage( + message: _listStore.error!, + icon: Icons.error, + ), + ) + else if (_listStore.streams.isEmpty) + if (_listStore.isLoading && _listStore.error == null) + const SliverFillRemaining( + hasScrollBody: false, + child: LoadingIndicator( + subtitle: 'Loading streams...', + ), + ) + else + const SliverFillRemaining( + hasScrollBody: false, + child: AlertMessage( + message: 'No streams found', + ), + ) + else + SliverSafeArea( + top: false, + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index > _listStore.streams.length - 8 && _listStore.hasMore) { + _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, + ), + ); + }, + childCount: _listStore.streams.length, + ), + ), + ), ], ), SafeArea( child: Observer( builder: (context) => AnimatedSwitcher( duration: const Duration(milliseconds: 200), - child: widget.listStore.showJumpButton ? ScrollToTopButton(scrollController: widget.listStore.scrollController) : null, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + child: _listStore.showJumpButton ? ScrollToTopButton(scrollController: _listStore.scrollController!) : null, ), ), ), @@ -128,4 +181,10 @@ class _CategoryStreamsState extends State { ), ); } + + @override + void dispose() { + _listStore.dispose(); + super.dispose(); + } } diff --git a/lib/screens/home/top/top_section.dart b/lib/screens/home/top/top_section.dart index 058dbd4a..08b2f0e8 100644 --- a/lib/screens/home/top/top_section.dart +++ b/lib/screens/home/top/top_section.dart @@ -1,44 +1,73 @@ import 'package:flutter/material.dart'; +import 'package:frosty/screens/home/stores/home_store.dart'; import 'package:frosty/screens/home/stores/list_store.dart'; import 'package:frosty/screens/home/top/categories.dart'; import 'package:frosty/screens/home/widgets/streams_list.dart'; -class TopSection extends StatelessWidget { - const TopSection({Key? key}) : super(key: key); +class TopSection extends StatefulWidget { + final HomeStore homeStore; + + const TopSection({ + Key? key, + required this.homeStore, + }) : super(key: key); + + @override + State createState() => _TopSectionState(); +} + +class _TopSectionState extends State with SingleTickerProviderStateMixin { + late final _tabBarController = TabController(length: 2, vsync: this); + + @override + void initState() { + super.initState(); + _tabBarController.addListener(() => widget.homeStore.topSectionCurrentIndex = _tabBarController.index); + } @override Widget build(BuildContext context) { - return DefaultTabController( - length: 2, - child: Column( - children: const [ - TabBar( - indicatorColor: Color(0xff9146ff), - tabs: [ - Tab( - child: Text( - 'Streams', - style: TextStyle(fontWeight: FontWeight.w600), - ), + return Column( + children: [ + TabBar( + controller: _tabBarController, + indicatorColor: const Color(0xff9146ff), + tabs: const [ + Tab( + child: Text( + 'Streams', + style: TextStyle(fontWeight: FontWeight.w600), ), - Tab( - child: Text( - 'Categories', - style: TextStyle(fontWeight: FontWeight.w600), - ), + ), + Tab( + child: Text( + 'Categories', + style: TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + Expanded( + child: TabBarView( + controller: _tabBarController, + children: [ + StreamsList( + listType: ListType.top, + scrollController: widget.homeStore.topSectionScrollControllers[0], + ), + Categories( + scrollController: widget.homeStore.topSectionScrollControllers[1], ), ], ), - Expanded( - child: TabBarView( - children: [ - StreamsList(listType: ListType.top), - Categories(), - ], - ), - ), - ], - ), + ), + ], ); } + + @override + void dispose() { + _tabBarController.dispose(); + super.dispose(); + } } diff --git a/lib/screens/home/widgets/category_card.dart b/lib/screens/home/widgets/category_card.dart index c6d0e8c7..c5bce054 100644 --- a/lib/screens/home/widgets/category_card.dart +++ b/lib/screens/home/widgets/category_card.dart @@ -1,39 +1,34 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -import 'package:frosty/api/twitch_api.dart'; -import 'package:frosty/core/auth/auth_store.dart'; import 'package:frosty/models/category.dart'; -import 'package:frosty/screens/home/stores/list_store.dart'; import 'package:frosty/screens/home/top/category_streams.dart'; +import 'package:frosty/widgets/animate_scale.dart'; import 'package:frosty/widgets/loading_indicator.dart'; -import 'package:provider/provider.dart'; -/// A tappable card widget that displays a category's box art and name. +/// A tappable card widget that displays a category's box art and name under. class CategoryCard extends StatelessWidget { final CategoryTwitch category; - final int width; - final int height; const CategoryCard({ Key? key, required this.category, - required this.width, - required this.height, }) : super(key: key); @override Widget build(BuildContext context) { - return InkWell( + // Calculate the dimmensions of the box art based on the current dimmensions of the screen. + final size = MediaQuery.of(context).size; + final pixelRatio = MediaQuery.of(context).devicePixelRatio; + final artWidth = (size.width * pixelRatio) ~/ 3; + final artHeight = (artWidth * (4 / 3)).toInt(); + + return AnimateScale( onTap: () => Navigator.push( context, MaterialPageRoute( builder: (context) => CategoryStreams( - listStore: ListStore( - twitchApi: context.read(), - authStore: context.read(), - listType: ListType.category, - categoryInfo: category, - ), + categoryName: category.name, + categoryId: category.id, ), ), ), @@ -47,7 +42,7 @@ class CategoryCard extends StatelessWidget { child: AspectRatio( aspectRatio: 3 / 4, child: CachedNetworkImage( - imageUrl: category.boxArtUrl.replaceRange(category.boxArtUrl.lastIndexOf('-') + 1, null, '${width}x$height.jpg'), + imageUrl: category.boxArtUrl.replaceRange(category.boxArtUrl.lastIndexOf('-') + 1, null, '${artWidth}x$artHeight.jpg'), placeholder: (context, url) => const LoadingIndicator(), ), ), diff --git a/lib/screens/home/widgets/stream_card.dart b/lib/screens/home/widgets/stream_card.dart index b08e5c31..eed2fb7b 100644 --- a/lib/screens/home/widgets/stream_card.dart +++ b/lib/screens/home/widgets/stream_card.dart @@ -1,12 +1,14 @@ +import 'dart:math'; + import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -import 'package:frosty/api/twitch_api.dart'; +import 'package:flutter/services.dart'; import 'package:frosty/constants/constants.dart'; import 'package:frosty/core/auth/auth_store.dart'; import 'package:frosty/models/stream.dart'; import 'package:frosty/screens/channel/video_chat.dart'; -import 'package:frosty/screens/home/stores/list_store.dart'; import 'package:frosty/screens/home/top/category_streams.dart'; +import 'package:frosty/widgets/animate_scale.dart'; import 'package:frosty/widgets/block_report_modal.dart'; import 'package:frosty/widgets/loading_indicator.dart'; import 'package:frosty/widgets/profile_picture.dart'; @@ -14,188 +16,226 @@ import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; /// A tappable card widget that displays a stream's thumbnail and details. -class StreamCard extends StatefulWidget { - final ListStore listStore; +class StreamCard extends StatelessWidget { final StreamTwitch streamInfo; - final int width; - final int height; final bool showUptime; final bool showThumbnail; + final bool large; final bool showCategory; const StreamCard({ Key? key, - required this.listStore, required this.streamInfo, - required this.width, - required this.height, required this.showUptime, required this.showThumbnail, + required this.large, this.showCategory = true, }) : super(key: key); - @override - State createState() => _StreamCardState(); -} - -class _StreamCardState extends State { @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(); - final thumbnail = AspectRatio( + // 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) ~/ (large ? 1 : 3), 1920); + final thumbnailHeight = min((thumbnailWidth * (9 / 16)).toInt(), 1080); + + final image = AspectRatio( aspectRatio: 16 / 9, child: CachedNetworkImage( - imageUrl: widget.streamInfo.thumbnailUrl.replaceFirst('-{width}x{height}', '-${widget.width}x${widget.height}') + cacheUrlExtension, + imageUrl: streamInfo.thumbnailUrl.replaceFirst('-{width}x{height}', '-${thumbnailWidth}x$thumbnailHeight') + cacheUrlExtension, placeholder: (context, url) => const LoadingIndicator(), useOldImageOnUrlChange: true, ), ); - final streamerName = regexEnglish.hasMatch(widget.streamInfo.userName) - ? widget.streamInfo.userName - : '${widget.streamInfo.userName} (${widget.streamInfo.userLogin})'; + final thumbnail = large + ? Container( + foregroundDecoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black, + ], + ), + ), + child: image, + ) + : image; - return InkWell( + final streamerName = regexEnglish.hasMatch(streamInfo.userName) ? streamInfo.userName : '${streamInfo.userName} (${streamInfo.userLogin})'; + + final subFontSize = large ? 16.0 : 14.0; + + final fontColor = large ? Colors.white : 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: Text( + DateTime.now().difference(DateTime.parse(streamInfo.startedAt)).toString().split('.')[0], + style: TextStyle( + fontSize: 16.0, + color: Colors.white.withOpacity(0.8), + ), + ), + ) + else + Container( + color: const Color.fromRGBO(0, 0, 0, 0.5), + padding: const EdgeInsets.symmetric(horizontal: 2.0), + child: Text( + DateTime.now().difference(DateTime.parse(streamInfo.startedAt)).toString().split('.')[0], + style: const TextStyle(fontSize: 12, color: Colors.white), + ), + ) + ], + ) + : thumbnail, + ); + + final streamInfoSection = Padding( + padding: large ? const EdgeInsets.all(10.0) : const EdgeInsets.only(left: 10.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + ProfilePicture( + userLogin: streamInfo.userLogin, + radius: 10.0, + ), + const SizedBox(width: 5.0), + Flexible( + child: Tooltip( + message: streamerName, + preferBelow: false, + child: Text( + streamerName, + style: TextStyle( + fontSize: large ? 20.0 : 16.0, + fontWeight: FontWeight.bold, + color: fontColor, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ), + const SizedBox(height: 5.0), + Tooltip( + message: streamInfo.title.trim(), + preferBelow: false, + padding: const EdgeInsets.all(10.0), + child: Text( + streamInfo.title.trim(), + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: FontWeight.w500, + fontSize: subFontSize, + color: fontColor, + ), + ), + ), + const SizedBox(height: 5.0), + if (showCategory) ...[ + InkWell( + child: Tooltip( + message: streamInfo.gameName, + preferBelow: false, + child: Text( + streamInfo.gameName, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: subFontSize, + color: fontColor?.withOpacity(0.8), + ), + ), + ), + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => CategoryStreams( + categoryName: streamInfo.gameName, + categoryId: streamInfo.gameId, + ), + ), + ), + ), + const SizedBox(height: 5.0), + ], + Text( + '${NumberFormat().format(streamInfo.viewerCount)} viewers', + style: TextStyle( + fontSize: subFontSize, + color: fontColor?.withOpacity(0.8), + ), + ), + ], + ), + ); + + return AnimateScale( onTap: () => Navigator.push( context, MaterialPageRoute( builder: (context) => VideoChat( - userId: widget.streamInfo.userId, - userName: widget.streamInfo.userName, - userLogin: widget.streamInfo.userLogin, + userId: streamInfo.userId, + userName: streamInfo.userName, + userLogin: streamInfo.userLogin, ), ), ), - onLongPress: () => showModalBottomSheet( - context: context, - builder: (context) => BlockReportModal( - authStore: widget.listStore.authStore, - name: streamerName, - userLogin: widget.streamInfo.userLogin, - userId: widget.streamInfo.userId, - ), - ), - child: Padding( - padding: EdgeInsets.symmetric(vertical: 10.0, horizontal: widget.showThumbnail ? 15.0 : 5.0), - child: Row( - children: [ - if (widget.showThumbnail) - Flexible( - flex: 1, - child: ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(5.0)), - child: widget.showUptime - ? Stack( - alignment: AlignmentDirectional.bottomEnd, - children: [ - thumbnail, - Container( - color: const Color.fromRGBO(0, 0, 0, 0.5), - padding: const EdgeInsets.symmetric(horizontal: 2.0), - child: Text( - DateTime.now().difference(DateTime.parse(widget.streamInfo.startedAt)).toString().split('.')[0], - style: const TextStyle(fontSize: 12, color: Colors.white), - ), - ) - ], - ) - : thumbnail, - ), - ), - Flexible( - flex: 2, - child: Padding( - padding: const EdgeInsets.only(left: 10.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - ProfilePicture( - userLogin: widget.streamInfo.userLogin, - radius: 10.0, - ), - const SizedBox(width: 5.0), - Flexible( - child: Tooltip( - message: streamerName, - preferBelow: false, - child: Text( - streamerName, - style: const TextStyle( - fontSize: 16.0, - fontWeight: FontWeight.bold, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ), - ], - ), - const SizedBox(height: 5.0), - Tooltip( - message: widget.streamInfo.title.trim(), - preferBelow: false, - padding: const EdgeInsets.all(10.0), - child: Text( - widget.streamInfo.title.trim(), - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontWeight: FontWeight.w500, - ), - ), - ), - const SizedBox(height: 5.0), - if (widget.showCategory) ...[ - InkWell( - child: Tooltip( - message: widget.streamInfo.gameName, - preferBelow: false, - child: Text( - widget.streamInfo.gameName, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: DefaultTextStyle.of(context).style.color?.withOpacity(0.8), - ), - ), - ), - onTap: () async { - final category = await context - .read() - .getCategory(headers: context.read().headersTwitch, gameId: widget.streamInfo.gameId); + onLongPress: () { + HapticFeedback.mediumImpact(); - if (!mounted) return; - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => CategoryStreams( - listStore: ListStore( - twitchApi: context.read(), - authStore: context.read(), - listType: ListType.category, - categoryInfo: category.data.first, - ), - ), - ), - ); - }, - ), - const SizedBox(height: 5.0), - ], - Text( - '${NumberFormat().format(widget.streamInfo.viewerCount)} viewers', - style: TextStyle( - color: DefaultTextStyle.of(context).style.color?.withOpacity(0.8), - ), + 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: 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, + ), + ], ), - ), - ], - ), ), ); } diff --git a/lib/screens/home/widgets/streams_list.dart b/lib/screens/home/widgets/streams_list.dart index ff41e64d..8a88be44 100644 --- a/lib/screens/home/widgets/streams_list.dart +++ b/lib/screens/home/widgets/streams_list.dart @@ -3,22 +3,26 @@ import 'package:flutter/services.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/api/twitch_api.dart'; import 'package:frosty/core/auth/auth_store.dart'; -import 'package:frosty/models/category.dart'; import 'package:frosty/screens/home/stores/list_store.dart'; import 'package:frosty/screens/home/widgets/stream_card.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; +import 'package:frosty/widgets/alert_message.dart'; import 'package:frosty/widgets/loading_indicator.dart'; -import 'package:frosty/widgets/scroll_to_top_button.dart'; import 'package:provider/provider.dart'; +/// A widget that displays a list of followed or top streams based on the provided [listType]. +/// For a widget that displays the top streams under a category, refer to [CategoryStreams]. class StreamsList extends StatefulWidget { + /// The type of list to display. final ListType listType; - final CategoryTwitch? categoryTwitch; + + /// The scroll controller to use for scroll to top functionality. + final ScrollController scrollController; const StreamsList({ Key? key, required this.listType, - this.categoryTwitch, + required this.scrollController, }) : super(key: key); @override @@ -30,26 +34,24 @@ class _StreamsListState extends State with AutomaticKeepAliveClient authStore: context.read(), twitchApi: context.read(), listType: widget.listType, - categoryInfo: widget.categoryTwitch, ); @override Widget build(BuildContext context) { super.build(context); - final size = MediaQuery.of(context).size; - final pixelRatio = MediaQuery.of(context).devicePixelRatio; - - final thumbnailWidth = (size.width * pixelRatio) ~/ 3; - final thumbnailHeight = (thumbnailWidth * (9 / 16)).toInt(); return RefreshIndicator( onRefresh: () async { HapticFeedback.lightImpact(); + await _listStore.refreshStreams(); if (_listStore.error != null) { final snackBar = SnackBar( - content: Text(_listStore.error!), + content: AlertMessage( + message: _listStore.error!, + icon: Icons.error, + ), behavior: SnackBarBehavior.floating, ); @@ -59,39 +61,54 @@ class _StreamsListState extends State with AutomaticKeepAliveClient }, child: Observer( builder: (_) { - if (_listStore.streams.isEmpty && _listStore.isLoading && _listStore.error == null) { - return const LoadingIndicator(subtitle: Text('Loading streams...')); + Widget? statusWidget; + + if (_listStore.error != null) { + statusWidget = AlertMessage( + message: _listStore.error!, + icon: Icons.error, + ); } - return Stack( - alignment: AlignmentDirectional.bottomCenter, - children: [ - ListView.builder( - physics: const AlwaysScrollableScrollPhysics(), - controller: _listStore.scrollController, - itemCount: _listStore.streams.length, - itemBuilder: (context, index) { - if (index > _listStore.streams.length / 2 && _listStore.hasMore) { - _listStore.getStreams(); - } - return Observer( - builder: (context) => StreamCard( - listStore: _listStore, - streamInfo: _listStore.streams[index], - width: thumbnailWidth, - height: thumbnailHeight, - showThumbnail: context.read().showThumbnails, - showUptime: context.read().showThumbnailUptime, - ), - ); - }, - ), - Observer( - builder: (context) => AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - child: _listStore.showJumpButton ? ScrollToTopButton(scrollController: _listStore.scrollController) : null, + + 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'); + } + } + + if (statusWidget != null) { + return CustomScrollView( + slivers: [ + SliverFillRemaining( + child: Center( + child: statusWidget, + ), + ) + ], + ); + } + + return ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + controller: widget.scrollController, + itemCount: _listStore.streams.length, + itemBuilder: (context, index) { + if (index > _listStore.streams.length - 8 && _listStore.hasMore) { + debugPrint('$index ${_listStore.streams.length}'); + + _listStore.getStreams(); + } + return Observer( + builder: (context) => StreamCard( + streamInfo: _listStore.streams[index], + showThumbnail: context.read().showThumbnails, + large: context.read().largeStreamCard, + showUptime: context.read().showThumbnailUptime, ), - ), - ], + ); + }, ); }, ), diff --git a/lib/screens/settings/sections/account_settings.dart b/lib/screens/settings/sections/account_settings.dart index 7e5a1408..fd3f9f88 100644 --- a/lib/screens/settings/sections/account_settings.dart +++ b/lib/screens/settings/sections/account_settings.dart @@ -5,7 +5,10 @@ import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/constants/constants.dart'; import 'package:frosty/core/auth/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 { @@ -60,30 +63,12 @@ class ProfileCard extends StatelessWidget { Future _showLoginDialog(BuildContext context) { return showDialog( context: context, - builder: (context) => SimpleDialog( - children: [ - ColoredBox( - color: const Color.fromRGBO(145, 70, 255, 0.8), - child: SimpleDialogOption( - padding: const EdgeInsets.all(24.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Image.asset( - 'assets/icons/TwitchGlitchWhite.png', - height: 25, - ), - const SizedBox(width: 10), - const Text( - 'Connect with Twitch', - style: TextStyle( - fontSize: 16, - color: Colors.white, - fontWeight: FontWeight.w600, - ), - ), - ], - ), + 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( @@ -101,29 +86,40 @@ class ProfileCard extends StatelessWidget { }, ), ), + 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(fontStyle: FontStyle.italic), + const SizedBox(height: 20.0), + const Center( + child: Text( + 'Or', + style: TextStyle(fontWeight: FontWeight.w600), + ), ), - ), - const SizedBox(height: 5.0), - SimpleDialogOption( - child: TextField( + const SizedBox(height: 20.0), + TextField( + autocorrect: false, textAlign: TextAlign.center, decoration: const InputDecoration( - labelText: 'Token', + hintText: 'Token', ), onSubmitted: (token) { authStore.login(token: token); Navigator.pop(context); }, - ), - ) - ], + ) + ], + ), ), ); } @@ -131,22 +127,23 @@ class ProfileCard extends StatelessWidget { Future _showLogoutDialog(BuildContext context) { return showDialog( context: context, - builder: (context) => AlertDialog( - title: const Text('Log Out'), + builder: (context) => FrostyDialog( + title: 'Log Out', content: const Text('Are you sure you want to log out?'), actions: [ - TextButton( - onPressed: Navigator.of(context).pop, - style: TextButton.styleFrom(primary: Colors.red), - child: const Text('Cancel'), - ), - ElevatedButton( + 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'), + ) ], ), ); @@ -154,51 +151,56 @@ class ProfileCard extends StatelessWidget { @override Widget build(BuildContext context) { - return Center( - child: Observer( - builder: (context) { - if (authStore.error != null) { - return ListTile( - title: const Text('Failed to Connect'), - trailing: OutlinedButton( - onPressed: authStore.init, - 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: OutlinedButton.icon( - onPressed: () => _showLogoutDialog(context), - icon: const Icon(Icons.logout_outlined), - label: const Text('Log Out'), - style: OutlinedButton.styleFrom(primary: Colors.red), - ), - ); - } + return Observer( + builder: (context) { + if (authStore.error != null) { 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: ElevatedButton.icon( - onPressed: () => _showLoginDialog(context), - icon: const Icon(Icons.login), - label: const Text('Log In'), + 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'), + ), + ); + }, ); } } @@ -220,21 +222,32 @@ class BlockedUsers extends StatelessWidget { 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: Text('You don\'t have any blocked users.'), + 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: Text(displayName), + title: Tooltip( + preferBelow: false, + message: displayName, + child: Text( + displayName, + overflow: TextOverflow.ellipsis, + ), + ), trailing: BlockButton( authStore: authStore, targetUser: displayName, diff --git a/lib/screens/settings/sections/chat_settings.dart b/lib/screens/settings/sections/chat_settings.dart index 515a4449..53bfcce9 100644 --- a/lib/screens/settings/sections/chat_settings.dart +++ b/lib/screens/settings/sections/chat_settings.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; @@ -41,7 +43,7 @@ class _ChatSettingsState extends State { SwitchListTile.adaptive( isThreeLine: true, title: const Text('Autocomplete'), - subtitle: const Text('Shows a bar that suggests matching emotes and mentions when typing.'), + 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, ), @@ -49,31 +51,38 @@ class _ChatSettingsState extends State { ListTile( title: Row( children: [ - const Text('Message Delay'), + const Text('Message delay'), const Spacer(), Text('${settingsStore.chatDelay.toInt()} ${settingsStore.chatDelay == 1.0 ? 'second' : 'seconds'}'), ], ), - subtitle: Slider.adaptive( - value: settingsStore.chatDelay, - min: 0.0, - max: 30.0, - divisions: 30, - onChanged: (newValue) => settingsStore.chatDelay = newValue, + 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), + ], ), ), const SectionHeader( 'Layout', - fontSize: 12.0, padding: sectionPadding, + showDivider: true, ), SwitchListTile.adaptive( - title: const Text('Bottom Bar'), + title: const Text('Bottom bar'), value: settingsStore.showBottomBar, onChanged: (newValue) => settingsStore.showBottomBar = newValue, ), SwitchListTile.adaptive( - title: const Text('Landscape Chat on Left Side'), + title: const Text('Landscape chat on left side'), value: settingsStore.landscapeChatLeftSide, onChanged: (newValue) => settingsStore.landscapeChatLeftSide = newValue, ), @@ -81,77 +90,90 @@ class _ChatSettingsState extends State { ListTile( title: Row( children: [ - const Text('Landscape Chat Width'), + const Text('Chat width'), const Spacer(), Text('${(settingsStore.chatWidth * 100).toStringAsFixed(0)}%'), ], ), - subtitle: Slider.adaptive( - value: settingsStore.chatWidth, - min: 0.2, - max: 0.6, - divisions: 8, - onChanged: (newValue) => settingsStore.chatWidth = newValue, + subtitle: Column( + 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('Landscape Chat Overlay Opacity'), + const Text('Chat overlay opacity'), const Spacer(), Text('${(settingsStore.fullScreenChatOverlayOpacity * 100).toStringAsFixed(0)}%'), ], ), - subtitle: Slider.adaptive( - value: settingsStore.fullScreenChatOverlayOpacity, - min: 0.0, - max: 1.0, - divisions: 10, - onChanged: (newValue) => settingsStore.fullScreenChatOverlayOpacity = newValue, + 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( 'Emotes', - fontSize: 12.0, padding: sectionPadding, + showDivider: true, ), SwitchListTile.adaptive( isThreeLine: true, - title: const Text('Zero-Width Emotes'), + title: const Text('Zero-width emotes'), subtitle: const Text('Shows "stacked" emotes from BetterTTV and 7TV.'), value: settingsStore.showZeroWidth, onChanged: (newValue) => settingsStore.showZeroWidth = newValue, ), const SectionHeader( 'Message Appearance', - fontSize: 12.0, padding: sectionPadding, + showDivider: true, ), SwitchListTile.adaptive( isThreeLine: true, - title: const Text('Readable Name Colors'), + 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'), + 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'), + 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'), + title: const Text('Message timestamps'), subtitle: const Text('Shows timestamps for when a message was sent.'), trailing: DropdownButton( value: settingsStore.timestampType, @@ -166,8 +188,8 @@ class _ChatSettingsState extends State { ), const SectionHeader( 'Message Sizing', - fontSize: 12.0, padding: sectionPadding, + showDivider: true, ), ExpansionTile( title: const Text('Preview'), @@ -229,7 +251,7 @@ class _ChatSettingsState extends State { ListTile( title: Row( children: [ - const Text('Badge Scale'), + const Text('Badge scale'), const Spacer(), Text('${settingsStore.badgeScale.toStringAsFixed(2)}x'), ], @@ -245,7 +267,7 @@ class _ChatSettingsState extends State { ListTile( title: Row( children: [ - const Text('Emote Scale'), + const Text('Emote scale'), const Spacer(), Text('${settingsStore.emoteScale.toStringAsFixed(2)}x'), ], @@ -261,7 +283,7 @@ class _ChatSettingsState extends State { ListTile( title: Row( children: [ - const Text('Message Scale'), + const Text('Message scale'), const Spacer(), Text('${settingsStore.messageScale.toStringAsFixed(2)}x'), ], @@ -277,7 +299,7 @@ class _ChatSettingsState extends State { ListTile( title: Row( children: [ - const Text('Message Spacing'), + const Text('Message spacing'), const Spacer(), Text(settingsStore.messageSpacing.toStringAsFixed(0).toString()), ], @@ -293,7 +315,7 @@ class _ChatSettingsState extends State { ListTile( title: Row( children: [ - const Text('Font Size'), + const Text('Font size'), const Spacer(), Text(settingsStore.fontSize.toInt().toString()), ], diff --git a/lib/screens/settings/sections/general_settings.dart b/lib/screens/settings/sections/general_settings.dart index 3e487434..1e160376 100644 --- a/lib/screens/settings/sections/general_settings.dart +++ b/lib/screens/settings/sections/general_settings.dart @@ -36,20 +36,25 @@ class GeneralSettings extends StatelessWidget { ), ), SwitchListTile.adaptive( - title: const Text('Stream Card Thumbnails'), + title: const Text('Large stream card'), + value: settingsStore.largeStreamCard, + onChanged: (newValue) => settingsStore.largeStreamCard = newValue, + ), + SwitchListTile.adaptive( + title: const Text('Stream card thumbnails'), value: settingsStore.showThumbnails, onChanged: (newValue) => settingsStore.showThumbnails = newValue, ), SwitchListTile.adaptive( isThreeLine: true, - title: const Text('Stream Uptime on Thumbnails'), + 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'), + title: const Text('Launch URLs in external browser'), subtitle: const Text('Opens links in the default external browser.'), value: settingsStore.launchUrlExternal, onChanged: (newValue) => settingsStore.launchUrlExternal = newValue, diff --git a/lib/screens/settings/sections/other_settings.dart b/lib/screens/settings/sections/other_settings.dart index fee1d161..0f48c9e3 100644 --- a/lib/screens/settings/sections/other_settings.dart +++ b/lib/screens/settings/sections/other_settings.dart @@ -1,9 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:frosty/constants/constants.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:package_info_plus/package_info_plus.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -24,28 +28,33 @@ class _OtherSettingsState extends State { Future _showConfirmDialog(BuildContext context) { return showDialog( context: context, - builder: (context) => AlertDialog( - title: const Text('Reset All Settings'), + builder: (context) => FrostyDialog( + title: 'Reset All Settings', content: const Text('Are you sure you want to reset all settings?'), actions: [ - TextButton( - onPressed: Navigator.of(context).pop, - style: TextButton.styleFrom(primary: Colors.red), - child: const Text('Cancel'), - ), - ElevatedButton( + Button( onPressed: () { + HapticFeedback.heavyImpact(); + widget.settingsStore.resetAllSettings(); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Settings reset!'), + content: AlertMessage(message: 'All settings reset'), behavior: SnackBarBehavior.floating, ), ); }, child: const Text('Yes'), ), + Button( + fill: true, + onPressed: Navigator.of(context).pop, + color: Colors.red.shade700, + child: const Text('Cancel'), + ), ], ), ); @@ -54,6 +63,7 @@ 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', @@ -89,7 +99,9 @@ class _OtherSettingsState extends State { ), Observer( builder: (_) => SwitchListTile.adaptive( - title: const Text('Send Anonymous Crash Logs'), + title: const Text('Send anonymous crash logs'), + isThreeLine: true, + subtitle: const Text('Help improve Frosty by sending anonymous crash logs through Sentry.'), value: widget.settingsStore.sendCrashLogs, onChanged: (newValue) { if (newValue == true) { @@ -101,32 +113,39 @@ class _OtherSettingsState extends State { }, ), ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 15.0), - width: double.infinity, - child: OutlinedButton.icon( + ...[ + Button( + padding: const EdgeInsets.symmetric(horizontal: 15.0, vertical: 10.0), icon: const Icon(Icons.delete_sweep), - label: const Text('Clear Image Cache'), + child: const Text('Clear Image Cache'), onPressed: () async { + HapticFeedback.mediumImpact(); + await DefaultCacheManager().emptyCache(); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Image cache cleared!'), + content: AlertMessage(message: 'Image cache cleared'), behavior: SnackBarBehavior.floating, ), ); }, ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 15.0), - width: double.infinity, - child: OutlinedButton.icon( - icon: const Icon(Icons.restore), - label: const Text('Reset All Settings'), + 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/sections/video_settings.dart b/lib/screens/settings/sections/video_settings.dart index 9b5f2631..e71635d7 100644 --- a/lib/screens/settings/sections/video_settings.dart +++ b/lib/screens/settings/sections/video_settings.dart @@ -29,7 +29,7 @@ class VideoSettings extends StatelessWidget { ), SwitchListTile.adaptive( isThreeLine: true, - title: const Text('Custom Overlay'), + title: const Text('Custom overlay'), subtitle: const Text('Replaces Twitch\'s default overlay with a mobile-friendly version.'), value: settingsStore.showOverlay, onChanged: settingsStore.showVideo ? (newValue) => settingsStore.showOverlay = newValue : null, @@ -37,14 +37,14 @@ class VideoSettings extends StatelessWidget { if (Platform.isIOS) SwitchListTile.adaptive( isThreeLine: true, - title: const Text('Picture-in-Picture Button (experimental)'), - subtitle: const Text('Adds a button to enter PiP mode on the bottom right of the overlay. MAY CAUSE freezes/crashes.'), + title: const Text('Picture-in-picture Button (experimental)'), + subtitle: const Text('Adds a button to enter PiP mode on the bottom right of the overlay (may cause freezes/crashes).'), value: settingsStore.pictureInPicture, onChanged: settingsStore.showVideo && settingsStore.showOverlay ? (newValue) => settingsStore.pictureInPicture = newValue : null, ), SwitchListTile.adaptive( isThreeLine: true, - title: const Text('Long-Press Player to Toggle Overlay'), + title: const Text('Long-press player to toggle overlay'), subtitle: const Text('Allows switching between Twitch\'s default overlay and the custom overlay.'), value: settingsStore.toggleableOverlay, onChanged: settingsStore.showVideo ? (newValue) => settingsStore.toggleableOverlay = newValue : null, @@ -53,17 +53,24 @@ class VideoSettings extends StatelessWidget { ListTile( title: Row( children: [ - const Text('Custom Overlay Opacity'), + const Text('Custom overlay opacity'), const Spacer(), Text('${(settingsStore.overlayOpacity * 100).toStringAsFixed(0)}%'), ], ), - subtitle: Slider.adaptive( - value: settingsStore.overlayOpacity, - min: 0.0, - max: 1.0, - divisions: 10, - onChanged: (newValue) => settingsStore.overlayOpacity = newValue, + 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), + ], ), ), ], diff --git a/lib/screens/settings/settings.dart b/lib/screens/settings/settings.dart index 936fd6f0..7b0675a2 100644 --- a/lib/screens/settings/settings.dart +++ b/lib/screens/settings/settings.dart @@ -25,13 +25,13 @@ class Settings extends StatelessWidget { actions: [ if (Platform.isAndroid) IconButton( - tooltip: 'Support the App', + tooltip: 'Support the app', onPressed: () => launchUrl(Uri.parse('https://www.buymeacoffee.com/tommychow'), mode: settingsStore.launchUrlExternal ? LaunchMode.externalApplication : LaunchMode.inAppWebView), icon: const FaIcon(FontAwesomeIcons.circleDollarToSlot), ), IconButton( - tooltip: 'View Source on GitHub', + 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), diff --git a/lib/screens/settings/stores/settings_store.dart b/lib/screens/settings/stores/settings_store.dart index fcf6a801..2e9360ae 100644 --- a/lib/screens/settings/stores/settings_store.dart +++ b/lib/screens/settings/stores/settings_store.dart @@ -15,6 +15,7 @@ abstract class _SettingsStoreBase with Store { // * General Settings static const defaultThemeType = ThemeType.system; static const defaultShowThumbnails = true; + static const defaultLargeStreamCard = false; static const defaultShowThumbnailUptime = false; static const defaultLaunchUrlExternal = false; @@ -26,6 +27,10 @@ abstract class _SettingsStoreBase with Store { @observable var showThumbnails = defaultShowThumbnails; + @JsonKey(defaultValue: defaultLargeStreamCard) + @observable + var largeStreamCard = defaultLargeStreamCard; + @JsonKey(defaultValue: defaultShowThumbnailUptime) @observable var showThumbnailUptime = defaultShowThumbnailUptime; @@ -37,8 +42,9 @@ abstract class _SettingsStoreBase with Store { @action void resetGeneralSettings() { themeType = defaultThemeType; - showThumbnailUptime = defaultShowThumbnailUptime; showThumbnails = defaultShowThumbnails; + largeStreamCard = defaultLargeStreamCard; + showThumbnailUptime = defaultShowThumbnailUptime; launchUrlExternal = defaultLaunchUrlExternal; } diff --git a/lib/screens/settings/stores/settings_store.g.dart b/lib/screens/settings/stores/settings_store.g.dart index 2d83af7a..c92b38db 100644 --- a/lib/screens/settings/stores/settings_store.g.dart +++ b/lib/screens/settings/stores/settings_store.g.dart @@ -12,6 +12,7 @@ SettingsStore _$SettingsStoreFromJson(Map json) => unknownValue: ThemeType.system) ?? 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 @@ -49,6 +50,7 @@ Map _$SettingsStoreToJson(SettingsStore instance) => { 'themeType': _$ThemeTypeEnumMap[instance.themeType], 'showThumbnails': instance.showThumbnails, + 'largeStreamCard': instance.largeStreamCard, 'showThumbnailUptime': instance.showThumbnailUptime, 'launchUrlExternal': instance.launchUrlExternal, 'showVideo': instance.showVideo, @@ -130,6 +132,22 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } + late final _$largeStreamCardAtom = + Atom(name: '_SettingsStoreBase.largeStreamCard', context: context); + + @override + bool get largeStreamCard { + _$largeStreamCardAtom.reportRead(); + return super.largeStreamCard; + } + + @override + set largeStreamCard(bool value) { + _$largeStreamCardAtom.reportWrite(value, super.largeStreamCard, () { + super.largeStreamCard = value; + }); + } + late final _$showThumbnailUptimeAtom = Atom(name: '_SettingsStoreBase.showThumbnailUptime', context: context); @@ -641,6 +659,7 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { return ''' themeType: ${themeType}, showThumbnails: ${showThumbnails}, +largeStreamCard: ${largeStreamCard}, showThumbnailUptime: ${showThumbnailUptime}, launchUrlExternal: ${launchUrlExternal}, showVideo: ${showVideo}, diff --git a/lib/widgets/alert_message.dart b/lib/widgets/alert_message.dart new file mode 100644 index 00000000..56f3702f --- /dev/null +++ b/lib/widgets/alert_message.dart @@ -0,0 +1,34 @@ +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; + + const AlertMessage({ + Key? key, + required this.message, + final this.icon = Icons.info, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + color: Colors.grey, + ), + const SizedBox(width: 5.0), + Text( + message, + style: const TextStyle( + fontWeight: FontWeight.w500, + color: Colors.grey, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/animate_scale.dart b/lib/widgets/animate_scale.dart new file mode 100644 index 00000000..1580e286 --- /dev/null +++ b/lib/widgets/animate_scale.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +/// A widget than animates its scale when tapped/held on. +class AnimateScale extends StatefulWidget { + final void Function()? onTap; + final void Function()? onLongPress; + final Widget child; + + const AnimateScale({ + Key? key, + this.onTap, + this.onLongPress, + required this.child, + }) : super(key: key); + + @override + State createState() => _AnimateScaleState(); +} + +class _AnimateScaleState extends State with SingleTickerProviderStateMixin { + late final _animationController = AnimationController( + vsync: this, + upperBound: 0.05, + ); + + @override + Widget build(BuildContext context) { + const duration = Duration(milliseconds: 200); + const downCurve = Curves.easeOutCubic; + const upCurve = Curves.easeInCubic; + + return InkWell( + onTap: widget.onTap, + onTapDown: (_) => _animationController.animateTo( + _animationController.upperBound, + curve: downCurve, + duration: duration, + ), + onLongPress: widget.onLongPress, + onTapUp: (_) => _animationController.animateTo( + _animationController.lowerBound, + curve: upCurve, + duration: duration, + ), + onTapCancel: () => _animationController.animateTo( + _animationController.lowerBound, + curve: upCurve, + duration: duration, + ), + child: AnimatedBuilder( + animation: _animationController, + child: widget.child, + builder: (context, child) => Transform.scale( + scale: 1 - _animationController.value, + child: child, + ), + ), + ); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } +} diff --git a/lib/widgets/block_button.dart b/lib/widgets/block_button.dart index 6b4ee7a5..ceb1035b 100644 --- a/lib/widgets/block_button.dart +++ b/lib/widgets/block_button.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/core/auth/auth_store.dart'; +import 'package:frosty/widgets/button.dart'; +import 'package:frosty/widgets/dialog.dart'; class BlockButton extends StatelessWidget { final AuthStore authStore; @@ -24,17 +26,12 @@ class BlockButton extends StatelessWidget { }) { return showDialog( context: context, - builder: (context) => AlertDialog( - title: isBlocked ? const Text('Unblock') : const Text('Block'), + 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: [ - TextButton( - onPressed: Navigator.of(context).pop, - style: TextButton.styleFrom(primary: Colors.red), - child: const Text('Cancel'), - ), - ElevatedButton( + Button( onPressed: () { if (isBlocked) { authStore.user.unblock(targetId: targetUserId, headers: authStore.headersTwitch); @@ -49,6 +46,12 @@ class BlockButton extends StatelessWidget { }, child: const Text('Yes'), ), + Button( + fill: true, + onPressed: Navigator.of(context).pop, + color: Colors.red.shade700, + child: const Text('Cancel'), + ), ], ), ); @@ -60,16 +63,18 @@ class BlockButton extends StatelessWidget { builder: (context) { final isBlocked = authStore.user.blockedUsers.where((blockedUser) => blockedUser.userId == targetUserId).isNotEmpty; - return OutlinedButton.icon( + return Button( + padding: const EdgeInsets.symmetric(horizontal: 15.0, vertical: 10.0), icon: const Icon(Icons.block), - label: isBlocked ? Text(simple ? 'Unblock' : 'Unblock $targetUser') : Text(simple ? 'Block' : 'Block $targetUser'), + color: Colors.red.shade700, + fill: true, onPressed: () => _showDialog( context, isBlocked: isBlocked, targetUser: targetUser, targetUserId: targetUserId, ), - style: OutlinedButton.styleFrom(primary: Colors.red), + 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 cfd221e9..8a9267e3 100644 --- a/lib/widgets/block_report_modal.dart +++ b/lib/widgets/block_report_modal.dart @@ -1,6 +1,9 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:frosty/core/auth/auth_store.dart'; import 'package:frosty/widgets/block_button.dart'; +import 'package:frosty/widgets/modal.dart'; import 'package:frosty/widgets/report_button.dart'; class BlockReportModal extends StatelessWidget { @@ -19,17 +22,10 @@ class BlockReportModal extends StatelessWidget { @override Widget build(BuildContext context) { - return SafeArea( + return FrostyModal( child: Column( mainAxisSize: MainAxisSize.min, children: [ - const SizedBox( - width: 50.0, - child: Divider( - height: 25.0, - thickness: 3.0, - ), - ), if (authStore.isLoggedIn) Container( padding: const EdgeInsets.symmetric(horizontal: 10.0), @@ -41,6 +37,7 @@ class BlockReportModal extends StatelessWidget { simple: false, ), ), + const SizedBox(height: 10.0), Container( padding: const EdgeInsets.symmetric(horizontal: 10.0), width: double.infinity, @@ -49,6 +46,7 @@ class BlockReportModal extends StatelessWidget { displayName: name, ), ), + if (Platform.isAndroid) const SizedBox(height: 20.0), ], ), ); diff --git a/lib/widgets/button.dart b/lib/widgets/button.dart new file mode 100644 index 00000000..1dddf96c --- /dev/null +++ b/lib/widgets/button.dart @@ -0,0 +1,121 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +/// A custom button that scales down when tapped/held on (sorta like a real button). +class Button extends StatefulWidget { + final Color? color; + final bool fill; + final EdgeInsets padding; + final double? fontSize; + final Widget? icon; + final Function()? onPressed; + final Widget child; + + const Button({ + Key? key, + this.color, + this.fill = false, + this.padding = const EdgeInsets.symmetric(vertical: 15.0, horizontal: 10.0), + this.fontSize, + this.icon, + required this.onPressed, + required this.child, + }) : super(key: key); + + @override + State