From 3a5f883b1f9623766abfae7246b0aa30ada00bb4 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Tue, 28 Jun 2022 10:32:16 -0400 Subject: [PATCH] Overhaul alert and error messages (#90) - Added new `AlertMessage` widget that displays an alert/error message with an icon. - Added some new error messages for followed, search, chatters, etc. - Changed various error messages to make them all consistent in style --- lib/api/bttv_api.dart | 4 +- lib/api/ffz_api.dart | 6 +- lib/api/seventv_api.dart | 2 +- lib/api/twitch_api.dart | 45 ++- .../channel/chat/details/chat_users_list.dart | 276 ++++++++++-------- .../channel/chat/widgets/chat_user_modal.dart | 68 +++-- .../channel/stores/chat_details_store.dart | 2 +- lib/screens/home/search/search.dart | 19 +- .../search/search_results_categories.dart | 26 +- .../home/search/search_results_channels.dart | 24 +- lib/screens/home/stores/categories_store.dart | 2 +- lib/screens/home/stores/list_store.dart | 2 +- lib/screens/home/top/categories.dart | 8 +- lib/screens/home/top/category_streams.dart | 12 +- lib/screens/home/widgets/streams_list.dart | 19 +- .../settings/sections/account_settings.dart | 8 +- .../settings/sections/other_settings.dart | 12 +- lib/widgets/alert_message.dart | 34 +++ lib/widgets/loading_indicator.dart | 22 +- 19 files changed, 353 insertions(+), 238 deletions(-) create mode 100644 lib/widgets/alert_message.dart 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/screens/channel/chat/details/chat_users_list.dart b/lib/screens/channel/chat/details/chat_users_list.dart index e9fdb34e..780fd889 100644 --- a/lib/screens/channel/chat/details/chat_users_list.dart +++ b/lib/screens/channel/chat/details/chat_users_list.dart @@ -7,6 +7,7 @@ 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'; @@ -62,141 +63,158 @@ class _ChattersListState extends State { 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)), - ), - ), - ), - ), - Expanded( - child: RefreshIndicator( - onRefresh: () async { - HapticFeedback.lightImpact(); + const SizedBox(height: 10.0), + Button( + padding: const EdgeInsets.symmetric(horizontal: 15.0), + onPressed: () => chatDetailsStore.updateChatters(widget.userLogin), + child: const Text('Try Again'), + ) + ], + ); + } - 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'), - const SizedBox(height: 10.0), - Button( - padding: const EdgeInsets.symmetric(horizontal: 15.0), - onPressed: () => chatDetailsStore.updateChatters(widget.userLogin), - child: const Text('Try Again'), - ) - ], - ); - } + if (chatDetailsStore.chatUsers == null) { + return const LoadingIndicator(subtitle: 'Getting chatters...'); + } - 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) ...[ - 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), - ), - ), - ), - 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: widget.chatStore, - username: userInfo.login, - userId: userInfo.id, - displayName: userInfo.displayName, - ), - ); - }, - ), - childCount: users.length, - ), - ), - ] - ], - ) - ], - ); - }, - ), - Observer( - builder: (context) => AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - child: chatDetailsStore.showJumpButton ? ScrollToTopButton(scrollController: _scrollController) : null, + 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), + ), + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(15.0)), ), ), - ], + ), ), - ), - ), - ], + Expanded( + child: RefreshIndicator( + onRefresh: () async { + HapticFeedback.lightImpact(); + + await chatDetailsStore.updateChatters(widget.userLogin); + }, + child: Stack( + alignment: AlignmentDirectional.bottomCenter, + children: [ + Observer( + builder: (context) { + 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, + ), + ), + ), + ), + 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: widget.chatStore, + username: userInfo.login, + userId: userInfo.id, + displayName: userInfo.displayName, + ), + ); + }, + ), + childCount: users.length, + ), + ), + ] + ], + ) + ], + ); + }, + ), + Observer( + builder: (context) => AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: chatDetailsStore.showJumpButton ? ScrollToTopButton(scrollController: _scrollController) : null, + ), + ), + ], + ), + ), + ), + ], + ); + }, ); } diff --git a/lib/screens/channel/chat/widgets/chat_user_modal.dart b/lib/screens/channel/chat/widgets/chat_user_modal.dart index 95b63a1f..c6bf1465 100644 --- a/lib/screens/channel/chat/widgets/chat_user_modal.dart +++ b/lib/screens/channel/chat/widgets/chat_user_modal.dart @@ -3,6 +3,7 @@ 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'; @@ -89,45 +90,48 @@ class _ChatUserModalState extends State { '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( - padding: const EdgeInsets.all(0.0), - reverse: true, - itemBuilder: (context, index) => InkWell( - onLongPress: () async { - HapticFeedback.lightImpact(); + 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: const EdgeInsets.all(0.0), + reverse: true, + itemBuilder: (context, index) => InkWell( + onLongPress: () async { + HapticFeedback.lightImpact(); - await Clipboard.setData(ClipboardData(text: userMessages[index].message)); + await Clipboard.setData(ClipboardData(text: userMessages[index].message)); - 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, + 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..e3ed5e39 100644 --- a/lib/screens/channel/stores/chat_details_store.dart +++ b/lib/screens/channel/stores/chat_details_store.dart @@ -60,7 +60,7 @@ abstract class ChatDetailsStoreBase with Store { _chatUsers = await twitchApi.getChatters(userLogin: userLogin); _error = null; } on SocketException { - _error = 'Failed to connect :('; + _error = 'Failed to connect'; } catch (e) { debugPrint(e.toString()); _error = e.toString(); diff --git a/lib/screens/home/search/search.dart b/lib/screens/home/search/search.dart index df78ee82..b9023f60 100644 --- a/lib/screens/home/search/search.dart +++ b/lib/screens/home/search/search.dart @@ -6,6 +6,7 @@ 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'; @@ -60,15 +61,21 @@ class _SearchState extends State { child: Observer( builder: (context) { if (_textEditingController.text.isEmpty || _searchStore.channelFuture == null || _searchStore.categoryFuture == null) { + 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', + fontSize: 12.0, + padding: headerPadding, + ), Expanded( child: ListView( children: _searchStore.searchHistory diff --git a/lib/screens/home/search/search_results_categories.dart b/lib/screens/home/search/search_results_categories.dart index 78432fa3..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'; @@ -20,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: @@ -35,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, + ), ), ); } @@ -45,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'), ), ); } diff --git a/lib/screens/home/search/search_results_channels.dart b/lib/screens/home/search/search_results_channels.dart index 78382013..615ba74f 100644 --- a/lib/screens/home/search/search_results_channels.dart +++ b/lib/screens/home/search/search_results_channels.dart @@ -5,6 +5,7 @@ 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/block_report_modal.dart'; import 'package:frosty/widgets/loading_indicator.dart'; import 'package:frosty/widgets/profile_picture.dart'; @@ -40,11 +41,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); } } @@ -59,13 +64,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: @@ -98,9 +107,8 @@ class _SearchResultsChannelsState extends State { ), ) : null, - subtitle: channel.isLive - ? Text('Uptime: ${DateTime.now().difference(DateTime.parse(channel.startedAt)).toString().split('.')[0]}') - : null, + subtitle: + channel.isLive ? Text('Uptime: ${DateTime.now().difference(DateTime.parse(channel.startedAt)).toString().split('.')[0]}') : null, onTap: () => Navigator.push( context, MaterialPageRoute( diff --git a/lib/screens/home/stores/categories_store.dart b/lib/screens/home/stores/categories_store.dart index 7767e24a..f5c3d50d 100644 --- a/lib/screens/home/stores/categories_store.dart +++ b/lib/screens/home/stores/categories_store.dart @@ -73,7 +73,7 @@ abstract class CategoriesStoreBase with Store { _error = null; } on SocketException { - _error = 'Failed to connect :('; + _error = 'Failed to connect'; } catch (e) { _error = e.toString(); } diff --git a/lib/screens/home/stores/list_store.dart b/lib/screens/home/stores/list_store.dart index 785fec39..82301263 100644 --- a/lib/screens/home/stores/list_store.dart +++ b/lib/screens/home/stores/list_store.dart @@ -119,7 +119,7 @@ abstract class ListStoreBase with Store { _error = null; } on SocketException { - _error = 'Failed to connect :('; + _error = 'Failed to connect'; } catch (e) { _error = e.toString(); } diff --git a/lib/screens/home/top/categories.dart b/lib/screens/home/top/categories.dart index 71a903b1..ff983907 100644 --- a/lib/screens/home/top/categories.dart +++ b/lib/screens/home/top/categories.dart @@ -5,6 +5,7 @@ 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'; @@ -33,7 +34,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, ); @@ -44,7 +48,7 @@ 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...')); + return const LoadingIndicator(subtitle: 'Loading categories...'); } return Stack( alignment: AlignmentDirectional.bottomCenter, diff --git a/lib/screens/home/top/category_streams.dart b/lib/screens/home/top/category_streams.dart index 2d75f478..3cacce57 100644 --- a/lib/screens/home/top/category_streams.dart +++ b/lib/screens/home/top/category_streams.dart @@ -8,6 +8,7 @@ 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'; @@ -45,7 +46,10 @@ class _CategoryStreamsState extends State { if (widget.listStore.error != null) { final snackBar = SnackBar( - content: Text(widget.listStore.error!), + content: AlertMessage( + message: widget.listStore.error!, + icon: Icons.error, + ), behavior: SnackBarBehavior.floating, ); @@ -103,10 +107,8 @@ class _CategoryStreamsState extends State { ), if (widget.listStore.streams.isEmpty && widget.listStore.isLoading && widget.listStore.error == null) const SliverFillRemaining( - child: Center( - child: LoadingIndicator( - subtitle: Text('Loading streams...'), - ), + child: LoadingIndicator( + subtitle: 'Loading streams...', ), ) else diff --git a/lib/screens/home/widgets/streams_list.dart b/lib/screens/home/widgets/streams_list.dart index 8d59de38..8382d749 100644 --- a/lib/screens/home/widgets/streams_list.dart +++ b/lib/screens/home/widgets/streams_list.dart @@ -6,6 +6,7 @@ import 'package:frosty/core/auth/auth_store.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'; @@ -49,7 +50,10 @@ class _StreamsListState extends State with AutomaticKeepAliveClient if (_listStore.error != null) { final snackBar = SnackBar( - content: Text(_listStore.error!), + content: AlertMessage( + message: _listStore.error!, + icon: Icons.error, + ), behavior: SnackBarBehavior.floating, ); @@ -59,9 +63,18 @@ 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...')); + if (_listStore.isLoading && _listStore.error == null) { + return const Center( + child: LoadingIndicator(subtitle: 'Loading streams...'), + ); } + + if (_listStore.streams.isEmpty) { + return const Center( + child: AlertMessage(message: 'No followed streams'), + ); + } + return Stack( alignment: AlignmentDirectional.bottomCenter, children: [ diff --git a/lib/screens/settings/sections/account_settings.dart b/lib/screens/settings/sections/account_settings.dart index 78f16a21..69a8d26c 100644 --- a/lib/screens/settings/sections/account_settings.dart +++ b/lib/screens/settings/sections/account_settings.dart @@ -5,6 +5,7 @@ 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'; @@ -161,7 +162,8 @@ class ProfileCard extends StatelessWidget { builder: (context) { if (authStore.error != null) { return ListTile( - title: const Text('Failed to Connect'), + leading: const Icon(Icons.error), + title: const Text('Failed to connect'), trailing: Button( onPressed: authStore.init, padding: const EdgeInsets.symmetric(horizontal: 15.0), @@ -234,7 +236,9 @@ class BlockedUsers extends StatelessWidget { 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( diff --git a/lib/screens/settings/sections/other_settings.dart b/lib/screens/settings/sections/other_settings.dart index d460eeb9..77f515c3 100644 --- a/lib/screens/settings/sections/other_settings.dart +++ b/lib/screens/settings/sections/other_settings.dart @@ -1,9 +1,11 @@ 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'; @@ -32,11 +34,15 @@ class _OtherSettingsState extends State { actions: [ 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, ), ); @@ -111,12 +117,14 @@ class _OtherSettingsState extends State { icon: const Icon(Icons.delete_sweep), child: const Text('Clear Image Cache'), onPressed: () async { + HapticFeedback.mediumImpact(); + await DefaultCacheManager().emptyCache(); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Image cache cleared!'), + content: AlertMessage(message: 'Image cache cleared'), behavior: SnackBarBehavior.floating, ), ); 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/loading_indicator.dart b/lib/widgets/loading_indicator.dart index 7b42bd2f..c0fb5b3e 100644 --- a/lib/widgets/loading_indicator.dart +++ b/lib/widgets/loading_indicator.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; class LoadingIndicator extends StatelessWidget { - final Widget? subtitle; + final String? subtitle; final double spacing; const LoadingIndicator({Key? key, this.subtitle, this.spacing = 10.0}) : super(key: key); @@ -11,15 +11,19 @@ class LoadingIndicator extends StatelessWidget { if (subtitle == null) { return const Center(child: CircularProgressIndicator.adaptive()); } - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const CircularProgressIndicator.adaptive(), - SizedBox(height: spacing), + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator.adaptive(), + SizedBox(height: spacing), + Text( subtitle!, - ], - ), + style: const TextStyle( + fontWeight: FontWeight.w500, + color: Colors.grey, + ), + ), + ], ); } }