Improve haptics & fix thumbnail card sizings (#90)

Also fixed full name not showing on buttons and modal for non-english
channels.
This commit is contained in:
Tommy Chow 2022-06-27 23:23:09 -04:00
parent c3b7b80b27
commit 23f48d3061
12 changed files with 196 additions and 188 deletions

View file

@ -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'),
),
),
),
),
],

View file

@ -93,6 +93,7 @@ class _ChattersListState extends State<ChattersList> {
child: RefreshIndicator(
onRefresh: () async {
HapticFeedback.lightImpact();
await chatDetailsStore.updateChatters(widget.userLogin);
},
child: Stack(
@ -148,32 +149,34 @@ class _ChattersListState extends State<ChattersList> {
),
),
),
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<TwitchApi>()
.getUser(headers: context.read<AuthStore>().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<TwitchApi>()
.getUser(headers: context.read<AuthStore>().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,
),
),
]

View file

@ -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<ChatUserModal> {
@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<ChatUserModal> {
),
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<ChatUserModal> {
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<ChatUserModal> {
reverse: true,
itemBuilder: (context, index) => InkWell(
onLongPress: () async {
HapticFeedback.lightImpact();
await Clipboard.setData(ClipboardData(text: userMessages[index].message));
if (!mounted) return;

View file

@ -132,6 +132,8 @@ abstract class VideoStoreBase with Store {
@action
Future<void> handleToggleOverlay() async {
if (settingsStore.toggleableOverlay) {
HapticFeedback.mediumImpact();
settingsStore.showOverlay = !settingsStore.showOverlay;
await controller?.loadUrl(videoUrl);

View file

@ -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,
),
),
)

View file

@ -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<SearchResultsChannels> {
),
),
),
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,
),
);
},
);
},
),

View file

@ -26,12 +26,6 @@ class _CategoriesState extends State<Categories> 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<Categories> with AutomaticKeepAliveClientMi
}
return CategoryCard(
category: _categoriesStore.categories[index],
width: artWidth,
height: artHeight,
);
},
),

View file

@ -27,9 +27,6 @@ class _CategoryStreamsState extends State<CategoryStreams> {
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<CategoryStreams> {
builder: (context) => StreamCard(
listStore: widget.listStore,
streamInfo: widget.listStore.streams[index],
width: thumbnailWidth,
height: thumbnailHeight,
showUptime: context.read<SettingsStore>().showThumbnailUptime,
showThumbnail: context.read<SettingsStore>().showThumbnails,
large: context.read<SettingsStore>().largeStreamCard,

View file

@ -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<CategoryCard> 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<TwitchApi>(),
authStore: context.read<AuthStore>(),
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<TwitchApi>(),
authStore: context.read<AuthStore>(),
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<CategoryCard> 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<CategoryCard> with SingleTickerProviderSt
],
),
),
),
builder: (context, child) => Transform.scale(
scale: 1 - _animationController.value,
child: child,
builder: (context, child) => Transform.scale(
scale: 1 - _animationController.value,
child: child,
),
),
);
}

View file

@ -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<StreamCard> 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<StreamCard> 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<StreamCard> 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<StreamCard> with SingleTickerProviderStateM
],
),
),
),
builder: (context, child) {
return Transform.scale(
builder: (context, child) => Transform.scale(
scale: 1 - _animationController.value,
child: child,
);
},
),
),
);
}

View file

@ -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<StreamsList> 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<StreamsList> 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<SettingsStore>().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<StreamsList> with AutomaticKeepAliveClient
builder: (context) => StreamCard(
listStore: _listStore,
streamInfo: _listStore.streams[index],
width: thumbnailWidth,
height: thumbnailHeight,
showThumbnail: context.read<SettingsStore>().showThumbnails,
large: context.read<SettingsStore>().largeStreamCard,
showUptime: context.read<SettingsStore>().showThumbnailUptime,

View file

@ -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),
],
),
),
);
}