diff --git a/lib/screens/channel/chat/chat.dart b/lib/screens/channel/chat/chat.dart index 38c6f3ad..d24f43e1 100644 --- a/lib/screens/channel/chat/chat.dart +++ b/lib/screens/channel/chat/chat.dart @@ -1,4 +1,5 @@ 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'; @@ -65,17 +66,21 @@ class Chat extends StatelessWidget { FocusScope.of(context).unfocus(); if (chatStore.assetsStore.showEmoteMenu) chatStore.assetsStore.showEmoteMenu = false; }, - onLongPress: () => 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']!, - ), - ), + 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, ); } @@ -87,17 +92,20 @@ class Chat extends StatelessWidget { ), ), ), - Observer( - builder: (_) => AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - child: chatStore.autoScroll - ? null - : Button( - padding: const EdgeInsets.all(10.0), - onPressed: chatStore.resumeScroll, - icon: const Icon(Icons.arrow_circle_down), - child: const Text('Resume Scroll'), - ), + Padding( + padding: const EdgeInsets.all(5.0), + child: Observer( + builder: (_) => AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: chatStore.autoScroll + ? null + : Button( + padding: const EdgeInsets.symmetric(horizontal: 15.0, vertical: 10.0), + onPressed: chatStore.resumeScroll, + icon: const Icon(Icons.arrow_circle_down), + child: const Text('Resume Scroll'), + ), + ), ), ), ], diff --git a/lib/screens/channel/chat/details/chat_users_list.dart b/lib/screens/channel/chat/details/chat_users_list.dart index 686fe9b7..e9fdb34e 100644 --- a/lib/screens/channel/chat/details/chat_users_list.dart +++ b/lib/screens/channel/chat/details/chat_users_list.dart @@ -93,6 +93,7 @@ class _ChattersListState extends State { child: RefreshIndicator( onRefresh: () async { HapticFeedback.lightImpact(); + await chatDetailsStore.updateChatters(widget.userLogin); }, child: Stack( @@ -148,32 +149,34 @@ class _ChattersListState extends State { ), ), ), - SliverPadding( - padding: const EdgeInsets.only(left: 10.0, right: 10.0, top: 5.0), - sliver: SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) => InkWell( + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => InkWell( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 5.0), child: Text(users[index]), - onLongPress: () async { - 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, + 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, ), ), ] diff --git a/lib/screens/channel/chat/widgets/chat_user_modal.dart b/lib/screens/channel/chat/widgets/chat_user_modal.dart index cee4a5b5..95b63a1f 100644 --- a/lib/screens/channel/chat/widgets/chat_user_modal.dart +++ b/lib/screens/channel/chat/widgets/chat_user_modal.dart @@ -1,5 +1,6 @@ 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/block_report_modal.dart'; @@ -30,6 +31,7 @@ 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 FrostyModal( child: SizedBox( @@ -44,12 +46,12 @@ class _ChatUserModalState extends State { ), title: Row( children: [ - Expanded( + Flexible( child: Tooltip( preferBelow: false, - message: widget.displayName, + message: name, child: Text( - widget.displayName, + name, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.bold), ), @@ -68,16 +70,20 @@ class _ChatUserModalState extends State { child: const Text('Reply'), ) : null, - onTap: () => showModalBottomSheet( - backgroundColor: Colors.transparent, - context: context, - builder: (context) => BlockReportModal( - authStore: widget.chatStore.auth, - name: widget.displayName, - userLogin: widget.username, - userId: widget.userId, - ), - ), + 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', @@ -93,6 +99,8 @@ class _ChatUserModalState extends State { reverse: true, itemBuilder: (context, index) => InkWell( onLongPress: () async { + HapticFeedback.lightImpact(); + await Clipboard.setData(ClipboardData(text: userMessages[index].message)); if (!mounted) return; 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/home/search/search_results_categories.dart b/lib/screens/home/search/search_results_categories.dart index 53de88f0..78432fa3 100644 --- a/lib/screens/home/search/search_results_categories.dart +++ b/lib/screens/home/search/search_results_categories.dart @@ -13,12 +13,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; @@ -64,8 +58,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 66f798e8..78382013 100644 --- a/lib/screens/home/search/search_results_channels.dart +++ b/lib/screens/home/search/search_results_channels.dart @@ -1,4 +1,5 @@ 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'; @@ -110,16 +111,20 @@ class _SearchResultsChannelsState extends State { ), ), ), - onLongPress: () => showModalBottomSheet( - backgroundColor: Colors.transparent, - 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, + ), + ); + }, ); }, ), diff --git a/lib/screens/home/top/categories.dart b/lib/screens/home/top/categories.dart index ceb5c549..5b663da0 100644 --- a/lib/screens/home/top/categories.dart +++ b/lib/screens/home/top/categories.dart @@ -26,12 +26,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(); @@ -68,8 +62,6 @@ class _CategoriesState extends State with AutomaticKeepAliveClientMi } return CategoryCard( category: _categoriesStore.categories[index], - width: artWidth, - height: artHeight, ); }, ), diff --git a/lib/screens/home/top/category_streams.dart b/lib/screens/home/top/category_streams.dart index e364e22b..55207fe8 100644 --- a/lib/screens/home/top/category_streams.dart +++ b/lib/screens/home/top/category_streams.dart @@ -27,9 +27,6 @@ class _CategoryStreamsState extends State { 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 { @@ -99,8 +96,6 @@ class _CategoryStreamsState extends State { builder: (context) => StreamCard( listStore: widget.listStore, streamInfo: widget.listStore.streams[index], - width: thumbnailWidth, - height: thumbnailHeight, showUptime: context.read().showThumbnailUptime, showThumbnail: context.read().showThumbnails, large: context.read().largeStreamCard, diff --git a/lib/screens/home/widgets/category_card.dart b/lib/screens/home/widgets/category_card.dart index 6c744869..91c99315 100644 --- a/lib/screens/home/widgets/category_card.dart +++ b/lib/screens/home/widgets/category_card.dart @@ -11,14 +11,10 @@ import 'package:provider/provider.dart'; /// A tappable card widget that displays a category's box art and name. class CategoryCard extends StatefulWidget { 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 @@ -33,37 +29,43 @@ class _CategoryCardState extends State with SingleTickerProviderSt @override Widget build(BuildContext context) { - return AnimatedBuilder( - animation: _animationController, - child: InkWell( - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => CategoryStreams( - listStore: ListStore( - twitchApi: context.read(), - authStore: context.read(), - listType: ListType.category, - categoryInfo: widget.category, - ), + 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 InkWell( + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => CategoryStreams( + listStore: ListStore( + twitchApi: context.read(), + authStore: context.read(), + listType: ListType.category, + categoryInfo: widget.category, ), ), ), - onTapDown: (_) => _animationController.animateTo( - _animationController.upperBound, - curve: Curves.easeOutBack, - duration: const Duration(milliseconds: 200), - ), - onTapUp: (_) => _animationController.animateTo( - _animationController.lowerBound, - curve: Curves.easeOutBack, - duration: const Duration(milliseconds: 300), - ), - onTapCancel: () => _animationController.animateTo( - _animationController.lowerBound, - curve: Curves.easeOutBack, - duration: const Duration(milliseconds: 300), - ), + ), + onTapDown: (_) => _animationController.animateTo( + _animationController.upperBound, + curve: Curves.easeOutBack, + duration: const Duration(milliseconds: 200), + ), + onTapUp: (_) => _animationController.animateTo( + _animationController.lowerBound, + curve: Curves.easeOutBack, + duration: const Duration(milliseconds: 300), + ), + onTapCancel: () => _animationController.animateTo( + _animationController.lowerBound, + curve: Curves.easeOutBack, + duration: const Duration(milliseconds: 300), + ), + child: AnimatedBuilder( + animation: _animationController, child: Padding( padding: const EdgeInsets.symmetric(vertical: 10.0, horizontal: 20.0), child: Column( @@ -74,8 +76,8 @@ class _CategoryCardState extends State with SingleTickerProviderSt child: AspectRatio( aspectRatio: 3 / 4, child: CachedNetworkImage( - imageUrl: widget.category.boxArtUrl - .replaceRange(widget.category.boxArtUrl.lastIndexOf('-') + 1, null, '${widget.width}x${widget.height}.jpg'), + imageUrl: + widget.category.boxArtUrl.replaceRange(widget.category.boxArtUrl.lastIndexOf('-') + 1, null, '${artWidth}x$artHeight.jpg'), placeholder: (context, url) => const LoadingIndicator(), ), ), @@ -95,10 +97,10 @@ class _CategoryCardState extends State with SingleTickerProviderSt ], ), ), - ), - builder: (context, child) => Transform.scale( - scale: 1 - _animationController.value, - child: child, + builder: (context, child) => Transform.scale( + scale: 1 - _animationController.value, + child: child, + ), ), ); } diff --git a/lib/screens/home/widgets/stream_card.dart b/lib/screens/home/widgets/stream_card.dart index 8331b6da..21cad42a 100644 --- a/lib/screens/home/widgets/stream_card.dart +++ b/lib/screens/home/widgets/stream_card.dart @@ -1,5 +1,8 @@ +import 'dart:math'; + import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:frosty/api/twitch_api.dart'; import 'package:frosty/constants/constants.dart'; import 'package:frosty/core/auth/auth_store.dart'; @@ -17,8 +20,6 @@ import 'package:provider/provider.dart'; class StreamCard extends StatefulWidget { final ListStore listStore; final StreamTwitch streamInfo; - final int width; - final int height; final bool showUptime; final bool showThumbnail; final bool large; @@ -28,8 +29,6 @@ class StreamCard extends StatefulWidget { Key? key, required this.listStore, required this.streamInfo, - required this.width, - required this.height, required this.showUptime, required this.showThumbnail, required this.large, @@ -51,10 +50,18 @@ class _StreamCardState extends State with SingleTickerProviderStateM final time = DateTime.now(); final cacheUrlExtension = time.day.toString() + time.hour.toString() + (time.minute ~/ 5).toString(); + final size = MediaQuery.of(context).size; + final pixelRatio = MediaQuery.of(context).devicePixelRatio; + + // 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 thumbnailWidth = min((size.width * pixelRatio) ~/ (widget.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: widget.streamInfo.thumbnailUrl.replaceFirst('-{width}x{height}', '-${thumbnailWidth}x$thumbnailHeight') + cacheUrlExtension, placeholder: (context, url) => const LoadingIndicator(), useOldImageOnUrlChange: true, ), @@ -208,20 +215,21 @@ class _StreamCardState extends State with SingleTickerProviderStateM ), ); - return AnimatedBuilder( - animation: _animationController, - child: InkWell( - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => VideoChat( - userId: widget.streamInfo.userId, - userName: widget.streamInfo.userName, - userLogin: widget.streamInfo.userLogin, - ), + return InkWell( + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => VideoChat( + userId: widget.streamInfo.userId, + userName: widget.streamInfo.userName, + userLogin: widget.streamInfo.userLogin, ), ), - onLongPress: () => showModalBottomSheet( + ), + onLongPress: () { + HapticFeedback.mediumImpact(); + + showModalBottomSheet( backgroundColor: Colors.transparent, context: context, builder: (context) => BlockReportModal( @@ -230,22 +238,25 @@ class _StreamCardState extends State with SingleTickerProviderStateM userLogin: widget.streamInfo.userLogin, userId: widget.streamInfo.userId, ), - ), - onTapDown: (_) => _animationController.animateTo( - _animationController.upperBound, - curve: Curves.easeOutBack, - duration: const Duration(milliseconds: 200), - ), - onTapUp: (_) => _animationController.animateTo( - _animationController.lowerBound, - curve: Curves.easeOutBack, - duration: const Duration(milliseconds: 300), - ), - onTapCancel: () => _animationController.animateTo( - _animationController.lowerBound, - curve: Curves.easeOutBack, - duration: const Duration(milliseconds: 300), - ), + ); + }, + onTapDown: (_) => _animationController.animateTo( + _animationController.upperBound, + curve: Curves.easeOutBack, + duration: const Duration(milliseconds: 200), + ), + onTapUp: (_) => _animationController.animateTo( + _animationController.lowerBound, + curve: Curves.easeOutBack, + duration: const Duration(milliseconds: 300), + ), + onTapCancel: () => _animationController.animateTo( + _animationController.lowerBound, + curve: Curves.easeOutBack, + duration: const Duration(milliseconds: 300), + ), + child: AnimatedBuilder( + animation: _animationController, child: Padding( padding: EdgeInsets.symmetric(vertical: 10.0, horizontal: widget.showThumbnail ? 15.0 : 5.0), child: widget.large @@ -267,13 +278,11 @@ class _StreamCardState extends State with SingleTickerProviderStateM ], ), ), - ), - builder: (context, child) { - return Transform.scale( + builder: (context, child) => Transform.scale( scale: 1 - _animationController.value, child: child, - ); - }, + ), + ), ); } diff --git a/lib/screens/home/widgets/streams_list.dart b/lib/screens/home/widgets/streams_list.dart index 725fd49f..c77bdbce 100644 --- a/lib/screens/home/widgets/streams_list.dart +++ b/lib/screens/home/widgets/streams_list.dart @@ -1,5 +1,3 @@ -import 'dart:math'; - import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; @@ -38,12 +36,11 @@ class _StreamsListState extends State with AutomaticKeepAliveClient @override Widget build(BuildContext context) { super.build(context); - final size = MediaQuery.of(context).size; - final pixelRatio = MediaQuery.of(context).devicePixelRatio; return RefreshIndicator( onRefresh: () async { HapticFeedback.lightImpact(); + await _listStore.refreshStreams(); if (_listStore.error != null) { @@ -58,11 +55,6 @@ class _StreamsListState extends State with AutomaticKeepAliveClient }, child: Observer( builder: (_) { - // 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 thumbnailWidth = min((size.width * pixelRatio) ~/ (context.read().largeStreamCard ? 1 : 3), 1920); - final thumbnailHeight = min((thumbnailWidth * (9 / 16)).toInt(), 1080); - if (_listStore.streams.isEmpty && _listStore.isLoading && _listStore.error == null) { return const LoadingIndicator(subtitle: Text('Loading streams...')); } @@ -81,8 +73,6 @@ class _StreamsListState extends State with AutomaticKeepAliveClient builder: (context) => StreamCard( listStore: _listStore, streamInfo: _listStore.streams[index], - width: thumbnailWidth, - height: thumbnailHeight, showThumbnail: context.read().showThumbnails, large: context.read().largeStreamCard, showUptime: context.read().showThumbnailUptime, diff --git a/lib/widgets/modal.dart b/lib/widgets/modal.dart index 367843c8..59487714 100644 --- a/lib/widgets/modal.dart +++ b/lib/widgets/modal.dart @@ -16,20 +16,22 @@ class FrostyModal extends StatelessWidget { topRight: Radius.circular(20.0), ), ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const FractionallySizedBox( - widthFactor: 0.25, - child: SizedBox( - child: Divider( - height: 25.0, - thickness: 3.0, + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const FractionallySizedBox( + widthFactor: 0.25, + child: SizedBox( + child: Divider( + height: 25.0, + thickness: 3.0, + ), ), ), - ), - SafeArea(child: child), - ], + Material(child: child), + ], + ), ), ); }