Improve design and usability, general tweaks and fixes (#226)

See #226 for more details.
This commit is contained in:
Tommy Chow 2023-01-18 05:34:54 -05:00 • committed by GitHub
parent 90841e0589
commit 7881ce22b4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
69 changed files with 3040 additions and 2812 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

View file

@ -2,7 +2,6 @@ import 'dart:convert';
import 'dart:io';
import 'package:device_preview/device_preview.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
@ -61,7 +60,7 @@ void main() async {
runApp(
DevicePreview(
enabled: !kReleaseMode,
enabled: false,
builder: (context) {
return MultiProvider(
providers: [
@ -83,25 +82,44 @@ void main() async {
final navigatorKey = GlobalKey<NavigatorState>();
const gray = Color.fromRGBO(18, 18, 18, 1.0);
const lightGray = Color.fromRGBO(28, 28, 28, 1.0);
const purple = Color(0xff9146ff);
const inputTheme = InputDecorationTheme(
filled: true,
contentPadding: EdgeInsets.all(10.0),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(30.0)),
borderRadius: BorderRadius.all(Radius.circular(10.0)),
borderSide: BorderSide(style: BorderStyle.none),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(30.0)),
borderRadius: BorderRadius.all(Radius.circular(10.0)),
borderSide: BorderSide(style: BorderStyle.none),
),
disabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(30.0)),
borderRadius: BorderRadius.all(Radius.circular(10.0)),
borderSide: BorderSide(style: BorderStyle.none),
),
);
const tooltipTheme = TooltipThemeData(
padding: EdgeInsets.all(10.0),
margin: EdgeInsets.symmetric(horizontal: 5.0),
decoration: BoxDecoration(
color: lightGray,
borderRadius: BorderRadius.all(Radius.circular(10.0)),
),
textStyle: TextStyle(color: Colors.white),
);
const snackBarTheme = SnackBarThemeData(
backgroundColor: lightGray,
contentTextStyle: TextStyle(color: Colors.white),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(10.0)),
),
);
final lightTheme = ThemeData(
canvasColor: Colors.white,
splashFactory: Platform.isIOS ? NoSplash.splashFactory : null,
@ -127,6 +145,8 @@ final lightTheme = ThemeData(
unselectedLabelColor: Colors.grey,
),
inputDecorationTheme: inputTheme,
tooltipTheme: tooltipTheme,
snackBarTheme: snackBarTheme,
);
final darkTheme = ThemeData(
@ -152,6 +172,8 @@ final darkTheme = ThemeData(
dialogBackgroundColor: gray,
toggleableActiveColor: purple,
inputDecorationTheme: inputTheme,
tooltipTheme: tooltipTheme,
snackBarTheme: snackBarTheme,
);
final oledTheme = ThemeData(
@ -175,6 +197,8 @@ final oledTheme = ThemeData(
dialogBackgroundColor: Colors.black,
toggleableActiveColor: purple,
inputDecorationTheme: inputTheme,
tooltipTheme: tooltipTheme,
snackBarTheme: snackBarTheme,
);
class MyApp extends StatelessWidget {
@ -191,7 +215,9 @@ class MyApp extends StatelessWidget {
locale: DevicePreview.locale(context),
title: 'Frosty',
theme: lightTheme,
darkTheme: settingsStore.themeType == ThemeType.dark || settingsStore.themeType == ThemeType.system ? darkTheme : oledTheme,
darkTheme: settingsStore.themeType == ThemeType.dark || settingsStore.themeType == ThemeType.system
? darkTheme
: oledTheme,
themeMode: settingsStore.themeType == ThemeType.system
? ThemeMode.system
: settingsStore.themeType == ThemeType.light

View file

@ -1,4 +1,3 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:collection/collection.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
@ -8,6 +7,7 @@ import 'package:frosty/models/badges.dart';
import 'package:frosty/models/emotes.dart';
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/cached_image.dart';
import 'package:intl/intl.dart';
import 'package:url_launcher/url_launcher.dart';
@ -112,7 +112,7 @@ class IRCMessage {
required double emoteScale,
required bool isLightTheme,
required bool launchExternal,
required void Function()? onLongPressName,
required void Function()? onTapName,
bool showMessage = true,
bool useReadableColors = false,
TimestampType timestamp = TimestampType.disabled,
@ -174,7 +174,8 @@ class IRCMessage {
badgeUrl = botBadge.url;
skipBot = true;
} else if (ffzRoomInfo?.modUrls != null) {
badgeUrl = 'https:${ffzRoomInfo!.modUrls?.url4x ?? ffzRoomInfo.modUrls?.url2x ?? ffzRoomInfo.modUrls!.url1x}';
badgeUrl =
'https:${ffzRoomInfo!.modUrls?.url4x ?? ffzRoomInfo.modUrls?.url2x ?? ffzRoomInfo.modUrls!.url1x}';
}
final newBadge = Badge(
@ -196,7 +197,8 @@ class IRCMessage {
// Add custom FFZ vip badge if it exists
if (badgeInfo.name == 'VIP' && ffzRoomInfo?.vipBadge != null) {
badgeUrl = 'https:${ffzRoomInfo!.vipBadge?.url4x ?? ffzRoomInfo.vipBadge?.url2x ?? ffzRoomInfo.vipBadge!.url1x}';
badgeUrl =
'https:${ffzRoomInfo!.vipBadge?.url4x ?? ffzRoomInfo.vipBadge?.url2x ?? ffzRoomInfo.vipBadge!.url1x}';
}
final newBadge = Badge(
@ -285,14 +287,15 @@ class IRCMessage {
}
// Add the display name (username) to the span and apply the onLongPressName callback.
final displayName = tags['display-name']!;
span.add(
TextSpan(
text: tags['display-name']!,
text: regexEnglish.hasMatch(displayName) ? displayName : '$displayName ($user)',
style: TextStyle(
color: color,
fontWeight: FontWeight.bold,
),
recognizer: LongPressGestureRecognizer()..onLongPress = onLongPressName,
recognizer: TapGestureRecognizer()..onTap = onTapName,
),
);
@ -351,11 +354,11 @@ class IRCMessage {
style: textStyle?.copyWith(fontSize: emoteSize - 5),
),
...emoteStack.reversed.map(
(emote) => CachedNetworkImage(
(emote) => FrostyCachedNetworkImage(
imageUrl: emote.url,
fadeInDuration: const Duration(),
height: emote.height != null ? emote.height! * emoteScale : emoteSize,
width: emote.width != null ? emote.width! * emoteScale : emoteSize,
useFade: false,
),
)
];
@ -366,43 +369,44 @@ class IRCMessage {
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Tooltip(
triggerMode: TooltipTriggerMode.tap,
showDuration: const Duration(seconds: 3),
richMessage: WidgetSpan(
child: Padding(
padding: const EdgeInsets.all(5.0),
child: Column(
children: [
Stack(
alignment: AlignmentDirectional.center,
children: [
if (nextWordIsEmoji)
Text(
words[index],
style: textStyle?.copyWith(fontSize: 75),
),
...emoteStack.reversed.map(
(emote) => CachedNetworkImage(
imageUrl: emote.url,
fadeInDuration: const Duration(),
height: 80,
),
)
],
),
const SizedBox(height: 5.0),
Column(
children: [
child: Column(
children: [
Stack(
alignment: AlignmentDirectional.center,
children: [
if (nextWordIsEmoji)
Text(
nextWordIsEmoji ? '${words[index]} - Emoji' : '${emoteStack.last.name} - ${emoteType[emoteStack.last.type.index]}',
style: const TextStyle(color: Colors.black),
words[index],
style: textStyle?.copyWith(fontSize: 75),
),
Text(
nextWordIsEmoji ? message.join(', ') : message.skip(1).join(', '),
style: const TextStyle(color: Colors.black),
...emoteStack.reversed.map(
(emote) => FrostyCachedNetworkImage(
imageUrl: emote.url,
height: 80,
useFade: false,
),
],
),
],
),
)
],
),
const SizedBox(height: 5.0),
Column(
children: [
Text(
nextWordIsEmoji
? '${words[index]} - Emoji'
: '${emoteStack.last.name} - ${emoteType[emoteStack.last.type.index]}',
style: const TextStyle(color: Colors.white),
),
Text(
nextWordIsEmoji ? message.join(', ') : message.skip(1).join(', '),
style: const TextStyle(color: Colors.white),
),
],
),
],
),
),
preferBelow: false,
@ -470,11 +474,11 @@ class IRCMessage {
if (backgroundColor != null) {
return ColoredBox(
color: backgroundColor,
child: CachedNetworkImage(
child: FrostyCachedNetworkImage(
imageUrl: badge.url,
fadeInDuration: const Duration(),
height: size,
width: size,
useFade: false,
),
);
} else if (isSvg == true) {
@ -484,11 +488,11 @@ class IRCMessage {
width: size,
);
} else {
return CachedNetworkImage(
return FrostyCachedNetworkImage(
imageUrl: badge.url,
fadeInDuration: const Duration(),
height: size,
width: size,
useFade: false,
);
}
}
@ -502,24 +506,23 @@ class IRCMessage {
return WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Tooltip(
triggerMode: TooltipTriggerMode.tap,
showDuration: const Duration(seconds: 3),
richMessage: WidgetSpan(
child: Padding(
padding: const EdgeInsets.all(5.0),
child: Column(
children: [
_createBadgeWidget(
badge: badge,
size: 80,
backgroundColor: backgroundColor,
isSvg: isSvg,
),
const SizedBox(height: 5.0),
Text(
badge.name,
style: const TextStyle(color: Colors.black),
),
],
),
child: Column(
children: [
_createBadgeWidget(
badge: badge,
size: 80,
backgroundColor: backgroundColor,
isSvg: isSvg,
),
const SizedBox(height: 5.0),
Text(
badge.name,
style: const TextStyle(color: Colors.white),
),
],
),
),
preferBelow: false,
@ -541,36 +544,35 @@ class IRCMessage {
return WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Tooltip(
triggerMode: TooltipTriggerMode.tap,
showDuration: const Duration(seconds: 3),
richMessage: WidgetSpan(
child: Padding(
padding: const EdgeInsets.all(5.0),
child: Column(
children: [
CachedNetworkImage(
imageUrl: emote.url,
fadeInDuration: const Duration(),
height: 80,
),
const SizedBox(height: 5.0),
Text(
emote.name,
style: const TextStyle(color: Colors.black),
),
Text(
emoteType[emote.type.index],
style: const TextStyle(color: Colors.black),
),
],
),
child: Column(
children: [
FrostyCachedNetworkImage(
imageUrl: emote.url,
height: 80,
useFade: false,
),
const SizedBox(height: 5.0),
Text(
emote.name,
style: const TextStyle(color: Colors.white),
),
Text(
emoteType[emote.type.index],
style: const TextStyle(color: Colors.white),
),
],
),
),
preferBelow: false,
child: CachedNetworkImage(
child: FrostyCachedNetworkImage(
imageUrl: emote.url,
placeholder: (context, url) => const SizedBox(),
fadeInDuration: const Duration(),
height: height,
width: width,
useFade: false,
placeholder: (context, url) => const SizedBox(),
),
),
);
@ -584,7 +586,8 @@ class IRCMessage {
text: text,
style: style?.copyWith(color: Colors.blue),
recognizer: TapGestureRecognizer()
..onTap = () => launchUrl(Uri.parse(text), mode: launchExternal ? LaunchMode.externalApplication : LaunchMode.inAppWebView),
..onTap = () => launchUrl(Uri.parse(text),
mode: launchExternal ? LaunchMode.externalApplication : LaunchMode.inAppWebView),
);
} else {
return TextSpan(text: text, style: style);
@ -633,7 +636,8 @@ class IRCMessage {
// If the username exists, set it.
// tmi.twitch.tv means the message was sent by Twitch rather than a user, so will be irrelevant.
final String? user = splitMessage[0] == 'tmi.twitch.tv' ? null : splitMessage[0].substring(0, splitMessage[0].indexOf('!'));
final String? user =
splitMessage[0] == 'tmi.twitch.tv' ? null : splitMessage[0].substring(0, splitMessage[0].indexOf('!'));
// If there is an associated message, set it.
//
@ -697,7 +701,11 @@ class IRCMessage {
if (userLogin != null) mention = message.toLowerCase().contains(userLogin);
// Escape the message
message = message.split(' ').map((word) => word.replaceAll('\u{E0000}', '').trim()).where((element) => element != '').join(' ');
message = message
.split(' ')
.map((word) => word.replaceAll('\u{E0000}', '').trim())
.where((element) => element != '')
.join(' ');
final emojiBuffer = StringBuffer();
final wordBuffer = StringBuffer();

View file

@ -14,12 +14,13 @@ import 'package:frosty/screens/channel/chat/details/chat_details_store.dart';
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/video/video.dart';
import 'package:frosty/screens/channel/video/video_bar.dart';
import 'package:frosty/screens/channel/video/video_overlay.dart';
import 'package:frosty/screens/channel/video/video_store.dart';
import 'package:frosty/screens/settings/settings.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/modal.dart';
import 'package:frosty/widgets/app_bar.dart';
import 'package:frosty/widgets/notification.dart';
import 'package:provider/provider.dart';
import 'package:simple_pip_mode/actions/pip_actions_layout.dart';
import 'package:simple_pip_mode/pip_widget.dart';
@ -72,32 +73,16 @@ class _VideoChatState extends State<VideoChat> {
@override
Widget build(BuildContext context) {
final orientation = MediaQuery.of(context).orientation;
final settingsStore = _chatStore.settings;
void showSettings() => showModalBottomSheet(
backgroundColor: Colors.transparent,
isScrollControlled: true,
context: context,
builder: (context) => FrostyModal(
child: SizedBox(
height: MediaQuery.of(context).size.height * 0.8,
child: Settings(settingsStore: settingsStore),
),
),
);
final appBar = AppBar(
final appBar = FrostyAppBar(
title: Text(
regexEnglish.hasMatch(_chatStore.displayName) ? _chatStore.displayName : '${_chatStore.displayName} (${_chatStore.channelName})',
style: const TextStyle(fontSize: 20),
regexEnglish.hasMatch(_chatStore.displayName)
? _chatStore.displayName
: '${_chatStore.displayName} (${_chatStore.channelName})',
),
actions: [
IconButton(
tooltip: 'Settings',
icon: const Icon(Icons.settings),
onPressed: showSettings,
),
],
);
final player = GestureDetector(
@ -108,14 +93,10 @@ class _VideoChatState extends State<VideoChat> {
),
);
final videoOverlay = VideoOverlay(
videoStore: _videoStore,
onSettingsPressed: showSettings,
);
final overlay = GestureDetector(
onLongPress: _videoStore.handleToggleOverlay,
onDoubleTap: MediaQuery.of(context).orientation == Orientation.landscape ? () => settingsStore.fullScreen = !settingsStore.fullScreen : null,
onDoubleTap:
orientation == Orientation.landscape ? () => settingsStore.fullScreen = !settingsStore.fullScreen : null,
onTap: () {
if (_chatStore.assetsStore.showEmoteMenu) {
_chatStore.assetsStore.showEmoteMenu = false;
@ -129,24 +110,20 @@ class _VideoChatState extends State<VideoChat> {
},
child: Observer(
builder: (_) {
final videoOverlay = VideoOverlay(
videoStore: _videoStore,
chatStore: _chatStore,
);
if (_videoStore.paused || _videoStore.streamInfo == null) return videoOverlay;
return AnimatedOpacity(
opacity: _videoStore.overlayVisible ? 1.0 : 0.0,
curve: Curves.ease,
duration: const Duration(milliseconds: 200),
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0.0, 0.25, 0.75, 1.0],
colors: [
Colors.black.withOpacity(settingsStore.overlayOpacity),
Colors.transparent,
Colors.transparent,
Colors.black.withOpacity(settingsStore.overlayOpacity),
],
),
color: Colors.black.withOpacity(settingsStore.overlayOpacity),
),
child: IgnorePointer(
ignoring: !_videoStore.overlayVisible,
@ -171,87 +148,147 @@ class _VideoChatState extends State<VideoChat> {
},
);
final chat = Chat(
key: _chatKey,
chatStore: _chatStore,
final chat = Observer(
builder: (context) {
final videoBarVisible = _videoStore.streamInfo != null &&
_chatStore.settings.showVideo &&
(_videoStore.paused || _videoStore.overlayVisible);
return Stack(
children: [
Chat(
key: _chatKey,
chatStore: _chatStore,
),
if (orientation == Orientation.portrait)
AnimatedOpacity(
opacity: videoBarVisible ? 1 : 0,
curve: Curves.ease,
duration: const Duration(milliseconds: 200),
child: IgnorePointer(
ignoring: !videoBarVisible,
child: ColoredBox(
color: Theme.of(context).canvasColor,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_videoStore.streamInfo != null)
VideoBar(
streamInfo: _videoStore.streamInfo!,
tappableCategory: false,
),
const Divider(height: 1, thickness: 1),
],
),
),
),
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: _chatStore.notification != null
? Align(
alignment:
_chatStore.settings.chatNotificationsOnBottom ? Alignment.bottomCenter : Alignment.topCenter,
child: FrostyNotification(
message: _chatStore.notification!,
showPasteButton: _chatStore.notification!.contains('copied'),
onButtonPressed: () async {
// Paste clipboard text into the text controller.
final data = await Clipboard.getData(Clipboard.kTextPlain);
if (data != null) _chatStore.textController.text = data.text!;
_chatStore.updateNotification('');
},
),
)
: null,
),
],
);
},
);
final videoChat = Scaffold(
body: OrientationBuilder(
builder: (context, orientation) {
return Observer(
builder: (_) {
if (orientation == Orientation.landscape && !settingsStore.landscapeForceVerticalChat) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
body: Observer(
builder: (context) {
if (orientation == Orientation.landscape && !settingsStore.landscapeForceVerticalChat) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
final landscapeChat = AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: _chatStore.expandChat ? MediaQuery.of(context).size.width / 2 : MediaQuery.of(context).size.width * _chatStore.settings.chatWidth,
curve: Curves.ease,
color: _chatStore.settings.fullScreen
? Colors.black.withOpacity(_chatStore.settings.fullScreenChatOverlayOpacity)
: Theme.of(context).scaffoldBackgroundColor,
child: chat,
);
final landscapeChat = AnimatedContainer(
curve: Curves.ease,
duration: const Duration(milliseconds: 200),
width: _chatStore.expandChat
? MediaQuery.of(context).size.width / 2
: MediaQuery.of(context).size.width * _chatStore.settings.chatWidth,
color: _chatStore.settings.fullScreen
? Colors.black.withOpacity(_chatStore.settings.fullScreenChatOverlayOpacity)
: Theme.of(context).scaffoldBackgroundColor,
child: chat,
);
final overlayChat = Visibility(
visible: settingsStore.fullScreenChatOverlay,
maintainState: true,
child: Theme(
data: darkTheme,
child: DefaultTextStyle(
style: DefaultTextStyle.of(context).style.copyWith(color: Colors.white),
child: landscapeChat,
),
),
);
return ColoredBox(
color: settingsStore.showVideo ? Colors.black : Theme.of(context).scaffoldBackgroundColor,
child: SafeArea(
bottom: false,
left:
(settingsStore.landscapeCutout == LandscapeCutoutType.both || settingsStore.landscapeCutout == LandscapeCutoutType.left) ? false : true,
right: (settingsStore.landscapeCutout == LandscapeCutoutType.both || settingsStore.landscapeCutout == LandscapeCutoutType.right)
? false
: true,
child: settingsStore.showVideo
? settingsStore.fullScreen
? Stack(
children: [
player,
if (settingsStore.showOverlay)
Row(
children: settingsStore.landscapeChatLeftSide
? [overlayChat, Expanded(child: overlay)]
: [Expanded(child: overlay), overlayChat],
)
],
)
: Row(
children:
settingsStore.landscapeChatLeftSide ? [landscapeChat, Expanded(child: video)] : [Expanded(child: video), landscapeChat],
)
: Column(
children: [appBar, Expanded(child: chat)],
),
),
);
}
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
return SafeArea(
child: Column(
children: [
if (!settingsStore.showVideo) appBar else AspectRatio(aspectRatio: 16 / 9, child: video),
Expanded(child: chat),
],
final overlayChat = Visibility(
visible: settingsStore.fullScreenChatOverlay,
maintainState: true,
child: Theme(
data: darkTheme,
child: DefaultTextStyle(
style: DefaultTextStyle.of(context).style.copyWith(color: Colors.white),
child: landscapeChat,
),
);
},
),
);
return ColoredBox(
color: settingsStore.showVideo ? Colors.black : Theme.of(context).scaffoldBackgroundColor,
child: SafeArea(
bottom: false,
left: (settingsStore.landscapeCutout == LandscapeCutoutType.both ||
settingsStore.landscapeCutout == LandscapeCutoutType.left)
? false
: true,
right: (settingsStore.landscapeCutout == LandscapeCutoutType.both ||
settingsStore.landscapeCutout == LandscapeCutoutType.right)
? false
: true,
child: settingsStore.showVideo
? settingsStore.fullScreen
? Stack(
children: [
player,
if (settingsStore.showOverlay)
Row(
children: settingsStore.landscapeChatLeftSide
? [overlayChat, Expanded(child: overlay)]
: [Expanded(child: overlay), overlayChat],
)
],
)
: Row(
children: settingsStore.landscapeChatLeftSide
? [landscapeChat, Expanded(child: video)]
: [Expanded(child: video), landscapeChat],
)
: Column(
children: [appBar, Expanded(child: chat)],
),
),
);
}
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
return SafeArea(
child: Column(
children: [
if (!settingsStore.showVideo) appBar else AspectRatio(aspectRatio: 16 / 9, child: video),
Expanded(child: chat),
],
),
);
},
),

View file

@ -1,12 +1,14 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/channel/chat/emote_menu/emote_menu.dart';
import 'package:frosty/screens/channel/chat/emote_menu/emote_menu_panel.dart';
import 'package:frosty/screens/channel/chat/emote_menu/recent_emotes_panel.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_bottom_bar.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_message.dart';
import 'package:frosty/widgets/button.dart';
import 'package:frosty/widgets/notification.dart';
import 'package:frosty/widgets/page_view.dart';
class Chat extends StatelessWidget {
final ChatStore chatStore;
@ -52,39 +54,19 @@ class Chat extends StatelessWidget {
),
),
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: chatStore.notification != null
? Align(
alignment: chatStore.settings.chatNotificationsOnBottom ? Alignment.bottomCenter : Alignment.topCenter,
child: FrostyNotification(
message: chatStore.notification!,
showPasteButton: chatStore.notification!.contains('copied'),
onButtonPressed: () async {
// Paste clipboard text into the text controller.
final data = await Clipboard.getData(Clipboard.kTextPlain);
if (data != null) chatStore.textController.text = data.text!;
chatStore.updateNotification('');
},
),
)
: null,
),
Padding(
padding: const EdgeInsets.all(5.0),
child: Observer(
builder: (_) => AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: chatStore.autoScroll
? null
: Button(
padding: const EdgeInsets.symmetric(horizontal: 15.0, vertical: 10.0),
onPressed: chatStore.resumeScroll,
icon: const Icon(Icons.arrow_downward),
icon: const Icon(Icons.keyboard_double_arrow_down_rounded),
child: const Text('Resume scroll'),
),
),
@ -95,15 +77,60 @@ class Chat extends StatelessWidget {
),
),
if (chatStore.settings.showBottomBar) ChatBottomBar(chatStore: chatStore),
AnimatedContainer(
curve: Curves.ease,
duration: const Duration(milliseconds: 200),
height: chatStore.assetsStore.showEmoteMenu ? MediaQuery.of(context).size.height / 3 : 0,
child: AnimatedOpacity(
WillPopScope(
onWillPop: Platform.isAndroid
? () async {
// If pressing the back button on Android while the emote menu is open, close it instead of going back to the streams list.
if (chatStore.assetsStore.showEmoteMenu) {
chatStore.assetsStore.showEmoteMenu = false;
return false;
} else {
return true;
}
}
: null,
child: AnimatedContainer(
curve: Curves.ease,
opacity: chatStore.assetsStore.showEmoteMenu ? 1 : 0,
duration: const Duration(milliseconds: 200),
child: EmoteMenu(chatStore: chatStore),
height: chatStore.assetsStore.showEmoteMenu
? MediaQuery.of(context).size.height /
(MediaQuery.of(context).orientation == Orientation.portrait ? 3 : 2)
: 0,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 100),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: chatStore.assetsStore.showEmoteMenu
? FrostyPageView(
headers: const [
'Recent',
'Twitch',
'7TV',
'BTTV',
'FFZ',
],
children: [
RecentEmotesPanel(
chatStore: chatStore,
),
EmoteMenuPanel(
chatStore: chatStore,
twitchEmotes: chatStore.assetsStore.userEmoteSectionToEmotes,
),
...[
chatStore.assetsStore.sevenTVEmotes,
chatStore.assetsStore.bttvEmotes,
chatStore.assetsStore.ffzEmotes
].map(
(emotes) => EmoteMenuPanel(
chatStore: chatStore,
emotes: emotes,
),
),
],
)
: null,
),
),
),
],

View file

@ -1,10 +1,17 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/main.dart';
import 'package:frosty/screens/channel/chat/details/chat_details_store.dart';
import 'package:frosty/screens/channel/chat/details/chat_modes.dart';
import 'package:frosty/screens/channel/chat/details/chat_users_list.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/widgets/modal.dart';
import 'package:frosty/widgets/section_header.dart';
import 'package:frosty/screens/settings/settings.dart';
import 'package:frosty/widgets/bottom_sheet.dart';
import 'package:frosty/widgets/button.dart';
import 'package:frosty/widgets/dialog.dart';
import 'package:frosty/widgets/list_tile.dart';
class ChatDetails extends StatefulWidget {
final ChatDetailsStore chatDetailsStore;
@ -23,6 +30,109 @@ class ChatDetails extends StatefulWidget {
}
class _ChatDetailsState extends State<ChatDetails> {
Future<void> _showSleepTimerDialog(BuildContext context) {
return showDialog(
context: context,
builder: (context) => FrostyDialog(
title: 'Sleep Timer',
content: Observer(
builder: (context) => Column(
mainAxisSize: MainAxisSize.min,
children: [
Opacity(
opacity: widget.chatStore.sleepTimer != null && widget.chatStore.sleepTimer!.isActive ? 1.0 : 0.5,
child: Row(
children: [
const Icon(Icons.timer_rounded),
Text(
' ${widget.chatStore.timeRemaining.toString().split('.')[0]}',
style: const TextStyle(fontWeight: FontWeight.w600, fontFeatures: [FontFeature.tabularFigures()]),
),
const Spacer(),
IconButton(
tooltip: 'Cancel sleep timer',
onPressed: widget.chatStore.cancelSleepTimer,
icon: const Icon(Icons.cancel_rounded),
),
],
),
),
Row(
children: [
DropdownButton(
value: widget.chatStore.sleepHours,
items: List.generate(24, (index) => index)
.map((e) => DropdownMenuItem(value: e, child: Text(e.toString())))
.toList(),
onChanged: (int? hours) => widget.chatStore.sleepHours = hours!,
menuMaxHeight: 200,
),
const SizedBox(width: 10.0),
const Text('Hours'),
],
),
Row(
children: [
DropdownButton(
value: widget.chatStore.sleepMinutes,
items: List.generate(60, (index) => index)
.map((e) => DropdownMenuItem(value: e, child: Text(e.toString())))
.toList(),
onChanged: (int? minutes) => widget.chatStore.sleepMinutes = minutes!,
menuMaxHeight: 200,
),
const SizedBox(width: 10.0),
const Text('Minutes'),
],
),
],
),
),
actions: [
Observer(
builder: (context) => Button(
onPressed: widget.chatStore.sleepHours == 0 && widget.chatStore.sleepMinutes == 0
? null
: () => widget.chatStore.updateSleepTimer(
onTimerFinished: () => navigatorKey.currentState?.popUntil((route) => route.isFirst),
),
child: const Text('Set timer'),
),
),
Button(
onPressed: Navigator.of(context).pop,
color: Colors.grey,
child: const Text('Close'),
),
],
),
);
}
Future<void> _showClearDialog() {
return showDialog(
context: context,
builder: (context) => FrostyDialog(
title: 'Clear Recent Emotes',
message: 'Are you sure you want to clear your recent emotes?',
actions: [
Button(
onPressed: () {
setState(widget.chatStore.assetsStore.recentEmotes.clear);
Navigator.pop(context);
},
child: const Text('Yes'),
),
Button(
onPressed: Navigator.of(context).pop,
color: Colors.grey,
child: const Text('Cancel'),
),
],
),
);
}
@override
void initState() {
super.initState();
@ -31,57 +141,81 @@ class _ChatDetailsState extends State<ChatDetails> {
@override
Widget build(BuildContext context) {
return FrostyModal(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionHeader('Chat modes'),
ListTile(
title: ChatModes(roomState: widget.chatDetailsStore.roomState),
),
const SectionHeader('Other'),
ListTile(
leading: const Icon(Icons.people),
title: const Text('Chatters'),
onTap: () => showModalBottomSheet(
backgroundColor: Colors.transparent,
isScrollControlled: true,
context: context,
builder: (context) => FrostyModal(
child: SizedBox(
height: MediaQuery.of(context).size.height * 0.8,
child: GestureDetector(
onTap: FocusScope.of(context).unfocus,
child: ChattersList(
chatDetailsStore: widget.chatDetailsStore,
chatStore: widget.chatStore,
userLogin: widget.userLogin,
),
),
final children = [
ListTile(
title: ChatModes(roomState: widget.chatDetailsStore.roomState),
),
FrostyListTile(
leading: const Icon(Icons.people_outline),
title: 'Chatters',
onTap: () => showModalBottomSheet(
backgroundColor: Colors.transparent,
isScrollControlled: true,
context: context,
builder: (context) => FrostyBottomSheet(
child: SizedBox(
height: MediaQuery.of(context).size.height * 0.8,
child: GestureDetector(
onTap: FocusScope.of(context).unfocus,
child: ChattersList(
chatDetailsStore: widget.chatDetailsStore,
chatStore: widget.chatStore,
userLogin: widget.userLogin,
),
),
),
),
ListTile(
leading: const Icon(Icons.refresh),
title: const Text('Reconnect to chat'),
onTap: () {
widget.chatStore.updateNotification('Reconnecting to chat...');
widget.chatStore.connectToChat();
},
),
ListTile(
leading: const Icon(Icons.refresh),
title: const Text('Refresh badges and emotes'),
onTap: () async {
await widget.chatStore.getAssets();
widget.chatStore.updateNotification('Badges and emotes refreshed');
},
),
],
),
),
FrostyListTile(
leading: const Icon(Icons.timer_outlined),
title: 'Sleep timer',
onTap: () => _showSleepTimerDialog(context),
),
FrostyListTile(
leading: const Icon(Icons.delete_outline_rounded),
title: 'Clear recent emotes',
onTap: _showClearDialog,
),
FrostyListTile(
leading: const Icon(Icons.refresh_rounded),
title: 'Reconnect to chat',
onTap: () {
widget.chatStore.updateNotification('Reconnecting to chat...');
widget.chatStore.connectToChat();
},
),
FrostyListTile(
leading: const Icon(Icons.refresh_rounded),
title: 'Refresh badges and emotes',
onTap: () async {
await widget.chatStore.getAssets();
widget.chatStore.updateNotification('Badges and emotes refreshed');
},
),
FrostyListTile(
leading: const Icon(Icons.settings_outlined),
title: 'Settings',
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => Settings(settingsStore: widget.chatStore.settings),
),
),
),
];
return FrostyBottomSheet(
child: MediaQuery.of(context).orientation == Orientation.landscape
? SizedBox(
height: MediaQuery.of(context).size.height * 0.8,
child: ListView(
children: children,
),
)
: Column(crossAxisAlignment: CrossAxisAlignment.start, children: children),
);
}
}

View file

@ -15,45 +15,59 @@ class ChatModes extends StatelessWidget {
children: [
Tooltip(
preferBelow: false,
triggerMode: TooltipTriggerMode.tap,
showDuration: const Duration(seconds: 3),
message: 'Emote-only mode ${roomState.emoteOnly != '0' ? 'on' : 'off'}',
child: Icon(
Icons.emoji_emotions_outlined,
roomState.emoteOnly != '0' ? Icons.emoji_emotions_rounded : Icons.emoji_emotions_outlined,
color: roomState.emoteOnly != '0' ? Colors.yellow : Colors.grey,
),
),
Tooltip(
preferBelow: false,
triggerMode: TooltipTriggerMode.tap,
showDuration: const Duration(seconds: 3),
message: roomState.followersOnly == '-1'
? 'Followers-only mode off'
: roomState.followersOnly == '0'
? 'Followers-only mode on'
: 'Followers-only mode on (${roomState.followersOnly} ${pluralize('minute', roomState.followersOnly)})',
child: Icon(
Icons.favorite,
roomState.followersOnly != '-1' ? Icons.favorite_rounded : Icons.favorite_outline_rounded,
color: roomState.followersOnly != '-1' ? Colors.red : Colors.grey,
),
),
Tooltip(
preferBelow: false,
message: 'R9K mode ${roomState.r9k != '0' ? 'on' : 'off'}',
triggerMode: TooltipTriggerMode.tap,
showDuration: const Duration(seconds: 3),
message: 'R9K (unique-chat) mode ${roomState.r9k != '0' ? 'on' : 'off'}',
child: Text(
'R9K',
style: TextStyle(color: roomState.r9k != '0' ? Colors.purple : Colors.grey),
style: TextStyle(
color: roomState.r9k != '0' ? Colors.purple : Colors.grey,
fontWeight: FontWeight.w500,
),
),
),
Tooltip(
preferBelow: false,
message: 'Slow mode ${roomState.slowMode != '0' ? 'on (${roomState.slowMode} ${pluralize('second', roomState.slowMode)})' : 'off'}',
triggerMode: TooltipTriggerMode.tap,
showDuration: const Duration(seconds: 3),
message:
'Slow mode ${roomState.slowMode != '0' ? 'on (${roomState.slowMode} ${pluralize('second', roomState.slowMode)})' : 'off'}',
child: Icon(
Icons.history_toggle_off,
roomState.slowMode != '0' ? Icons.hourglass_top_rounded : Icons.hourglass_empty_rounded,
color: roomState.slowMode != '0' ? Colors.blue : Colors.grey,
),
),
Tooltip(
preferBelow: false,
triggerMode: TooltipTriggerMode.tap,
showDuration: const Duration(seconds: 3),
message: 'Subs-only mode ${roomState.subMode != '0' ? 'on' : 'off'}',
child: Icon(
Icons.attach_money,
roomState.subMode != '0' ? Icons.monetization_on_rounded : Icons.monetization_on_outlined,
color: roomState.subMode != '0' ? Colors.green : Colors.grey,
),
),

View file

@ -15,7 +15,7 @@ import 'package:frosty/widgets/section_header.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
class ChattersList extends StatelessWidget {
class ChattersList extends StatefulWidget {
final ChatDetailsStore chatDetailsStore;
final ChatStore chatStore;
final String userLogin;
@ -27,13 +27,18 @@ class ChattersList extends StatelessWidget {
required this.userLogin,
}) : super(key: key);
@override
State<ChattersList> createState() => _ChattersListState();
}
class _ChattersListState extends State<ChattersList> {
@override
Widget build(BuildContext context) {
const headers = [
'Broadcaster',
'Staff',
'Admins',
'Global Moderators',
'Global moderators',
'Moderators',
'VIPs',
'Viewers',
@ -41,25 +46,24 @@ class ChattersList extends StatelessWidget {
return Observer(
builder: (context) {
if (chatDetailsStore.error != null) {
if (widget.chatDetailsStore.error != null) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const AlertMessage(
message: 'Failed to get chatters',
icon: Icons.error,
),
const SizedBox(height: 10.0),
Button(
padding: const EdgeInsets.symmetric(horizontal: 15.0),
onPressed: chatDetailsStore.updateChatters,
child: const Text('Try Again'),
onPressed: widget.chatDetailsStore.updateChatters,
child: const Text('Try again'),
)
],
);
}
if (chatDetailsStore.chatUsers == null) {
if (widget.chatDetailsStore.chatUsers == null) {
return const LoadingIndicator(subtitle: 'Getting chatters...');
}
@ -70,21 +74,24 @@ class ChattersList extends StatelessWidget {
child: Observer(
builder: (context) {
return TextField(
controller: chatDetailsStore.textController,
focusNode: chatDetailsStore.textFieldFocusNode,
controller: widget.chatDetailsStore.textController,
focusNode: widget.chatDetailsStore.textFieldFocusNode,
autocorrect: false,
decoration: InputDecoration(
contentPadding: EdgeInsets.zero,
prefixIcon: const Icon(Icons.filter_list),
prefixIcon: const Icon(Icons.filter_list_rounded),
hintText: 'Filter chatters',
suffixIcon: chatDetailsStore.textFieldFocusNode.hasFocus || chatDetailsStore.filterText.isNotEmpty
suffixIcon: widget.chatDetailsStore.textFieldFocusNode.hasFocus ||
widget.chatDetailsStore.filterText.isNotEmpty
? IconButton(
tooltip: chatDetailsStore.filterText.isEmpty ? 'Cancel' : 'Clear',
tooltip: widget.chatDetailsStore.filterText.isEmpty ? 'Cancel' : 'Clear',
onPressed: () {
if (chatDetailsStore.filterText.isEmpty) chatDetailsStore.textFieldFocusNode.unfocus();
chatDetailsStore.textController.clear();
if (widget.chatDetailsStore.filterText.isEmpty) {
widget.chatDetailsStore.textFieldFocusNode.unfocus();
}
widget.chatDetailsStore.textController.clear();
},
icon: const Icon(Icons.clear),
icon: const Icon(Icons.close_rounded),
)
: null,
),
@ -97,7 +104,7 @@ class ChattersList extends StatelessWidget {
onRefresh: () async {
HapticFeedback.lightImpact();
chatDetailsStore.updateChatters();
widget.chatDetailsStore.updateChatters();
},
child: Stack(
alignment: AlignmentDirectional.bottomCenter,
@ -106,14 +113,14 @@ class ChattersList extends StatelessWidget {
builder: (context) {
return CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
controller: chatDetailsStore.scrollController,
controller: widget.chatDetailsStore.scrollController,
slivers: [
if (chatDetailsStore.filterText.isEmpty)
if (widget.chatDetailsStore.filterText.isEmpty)
SliverPadding(
padding: const EdgeInsets.only(left: 10.0, right: 10.0, top: 20.0),
sliver: SliverToBoxAdapter(
child: Text(
'${NumberFormat().format(chatDetailsStore.chatUsers?.chatterCount)} ${chatDetailsStore.chatUsers?.chatterCount == 1 ? 'Chatter' : 'Chatters'}',
'${NumberFormat().format(widget.chatDetailsStore.chatUsers?.chatterCount)} ${widget.chatDetailsStore.chatUsers?.chatterCount == 1 ? 'Chatter' : 'Chatters'}',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18.0,
@ -121,28 +128,22 @@ class ChattersList extends StatelessWidget {
),
),
),
if (chatDetailsStore.chatUsers?.chatterCount == 0)
if (widget.chatDetailsStore.chatUsers?.chatterCount == 0)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: AlertMessage(
message: 'No chatters found',
icon: Icons.person_off,
),
child: AlertMessage(message: 'No chatters found'),
),
)
else if (chatDetailsStore.filteredUsers.expand((element) => element).isEmpty)
else if (widget.chatDetailsStore.filteredUsers.expand((element) => element).isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: AlertMessage(
message: 'No matching chatters',
icon: Icons.person_off,
),
child: AlertMessage(message: 'No matching chatters'),
),
)
else
...chatDetailsStore.filteredUsers.expandIndexed(
...widget.chatDetailsStore.filteredUsers.expandIndexed(
(index, users) => [
if (users.isNotEmpty) ...[
SliverPadding(
@ -164,16 +165,16 @@ class ChattersList extends StatelessWidget {
onLongPress: () async {
HapticFeedback.lightImpact();
final userInfo = await context
.read<TwitchApi>()
.getUser(headers: context.read<AuthStore>().headersTwitch, userLogin: users[index]);
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: chatStore,
chatStore: widget.chatStore,
username: userInfo.login,
userId: userInfo.id,
displayName: userInfo.displayName,
@ -194,9 +195,11 @@ class ChattersList extends StatelessWidget {
Observer(
builder: (context) => AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
child: chatDetailsStore.showJumpButton ? ScrollToTopButton(scrollController: chatDetailsStore.scrollController) : null,
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: widget.chatDetailsStore.showJumpButton
? ScrollToTopButton(scrollController: widget.chatDetailsStore.scrollController)
: null,
),
),
],
@ -208,4 +211,10 @@ class ChattersList extends StatelessWidget {
},
);
}
@override
void dispose() {
widget.chatDetailsStore.showJumpButton = false;
super.dispose();
}
}

View file

@ -1,136 +0,0 @@
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/channel/chat/emote_menu/emote_menu_panel.dart';
import 'package:frosty/screens/channel/chat/emote_menu/recent_emotes_panel.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/widgets/button.dart';
class EmoteMenu extends StatefulWidget {
final ChatStore chatStore;
const EmoteMenu({
Key? key,
required this.chatStore,
}) : super(key: key);
@override
State<EmoteMenu> createState() => _EmoteMenuState();
}
class _EmoteMenuState extends State<EmoteMenu> {
late final PageController _pageContoller = PageController(initialPage: widget.chatStore.assetsStore.emoteMenuIndex);
@override
Widget build(BuildContext context) {
const sections = [
'RECENT',
'TWITCH',
'7TV',
'BTTV',
'FFZ',
];
final emoteMenu = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Divider(
height: 5.0,
thickness: 1.0,
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
...sections.mapIndexed(
(index, section) => Padding(
padding: const EdgeInsets.only(right: 5.0),
child: Observer(
builder: (context) => Button(
onPressed: () {
_pageContoller.animateToPage(
index,
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
widget.chatStore.assetsStore.emoteMenuIndex = index;
},
color: index == widget.chatStore.assetsStore.emoteMenuIndex ? Theme.of(context).colorScheme.secondary : Colors.grey,
child: Text(
section,
style: const TextStyle(
letterSpacing: 0.8,
fontWeight: FontWeight.w600,
),
),
),
),
),
),
],
),
),
Expanded(
child: PageView(
onPageChanged: (index) => widget.chatStore.assetsStore.emoteMenuIndex = index,
controller: _pageContoller,
children: [
RecentEmotesPanel(
chatStore: widget.chatStore,
),
Observer(
builder: (_) => EmoteMenuPanel(
chatStore: widget.chatStore,
twitchEmotes: widget.chatStore.assetsStore.userEmoteSectionToEmotes,
),
),
Observer(
builder: (_) => EmoteMenuPanel(
chatStore: widget.chatStore,
emotes: widget.chatStore.assetsStore.sevenTVEmotes,
),
),
Observer(
builder: (_) => EmoteMenuPanel(
chatStore: widget.chatStore,
emotes: widget.chatStore.assetsStore.bttvEmotes,
),
),
Observer(
builder: (_) => EmoteMenuPanel(
chatStore: widget.chatStore,
emotes: widget.chatStore.assetsStore.ffzEmotes,
),
),
],
),
),
],
);
if (Platform.isAndroid) {
return WillPopScope(
onWillPop: () async {
// If pressing the back button on Android while the emote menu is open, close it instead of going back to the streams list.
if (widget.chatStore.assetsStore.showEmoteMenu) {
widget.chatStore.assetsStore.showEmoteMenu = false;
return false;
} else {
return true;
}
},
child: emoteMenu,
);
}
return emoteMenu;
}
@override
void dispose() {
_pageContoller.dispose();
super.dispose();
}
}

View file

@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:frosty/models/emotes.dart';
import 'package:frosty/screens/channel/chat/emote_menu/emote_menu_section.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/widgets/section_header.dart';
import 'package:frosty/widgets/page_view.dart';
class EmoteMenuPanel extends StatelessWidget {
final ChatStore chatStore;
@ -19,8 +19,12 @@ class EmoteMenuPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (emotes != null) {
final globalEmotes =
emotes!.where((emote) => emote.type == EmoteType.bttvGlobal || emote.type == EmoteType.ffzGlobal || emote.type == EmoteType.sevenTVGlobal).toList();
final globalEmotes = emotes!
.where((emote) =>
emote.type == EmoteType.bttvGlobal ||
emote.type == EmoteType.ffzGlobal ||
emote.type == EmoteType.sevenTVGlobal)
.toList();
final channelEmotes = emotes!
.where((emote) =>
@ -30,47 +34,24 @@ class EmoteMenuPanel extends StatelessWidget {
emote.type == EmoteType.sevenTVChannel)
.toList();
return CustomScrollView(
slivers: [
if (globalEmotes.isNotEmpty) ...[
const SliverToBoxAdapter(
child: SectionHeader(
'Global Emotes',
padding: EdgeInsets.symmetric(vertical: 5.0, horizontal: 10.0),
),
),
EmoteMenuSection(
chatStore: chatStore,
emotes: globalEmotes,
),
],
if (channelEmotes.isNotEmpty) ...[
const SliverToBoxAdapter(
child: SectionHeader('Channel Emotes'),
),
return FrostyPageView(
headers: ['Global', if (channelEmotes.isNotEmpty) 'Channel'],
children: [
EmoteMenuSection(
chatStore: chatStore,
emotes: globalEmotes,
),
if (channelEmotes.isNotEmpty)
EmoteMenuSection(
chatStore: chatStore,
emotes: channelEmotes,
),
],
],
);
} else {
return CustomScrollView(
slivers: [
for (final entry in twitchEmotes!.entries) ...[
SliverToBoxAdapter(
child: SectionHeader(
entry.key,
padding: entry.key == 'Global Emotes' ? const EdgeInsets.symmetric(vertical: 5.0, horizontal: 10.0) : null,
),
),
EmoteMenuSection(
chatStore: chatStore,
emotes: entry.value,
),
]
],
return FrostyPageView(
headers: twitchEmotes!.keys.map((header) => header.split(' ')[0]).toList(),
children: twitchEmotes!.entries.map((e) => EmoteMenuSection(chatStore: chatStore, emotes: e.value)).toList(),
);
}
}

View file

@ -1,9 +1,9 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/models/emotes.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/cached_image.dart';
import 'package:provider/provider.dart';
class EmoteMenuSection extends StatelessWidget {
@ -18,7 +18,7 @@ class EmoteMenuSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SliverGrid(
return GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: MediaQuery.of(context).orientation == Orientation.portrait
? 8
@ -26,26 +26,24 @@ class EmoteMenuSection extends StatelessWidget {
? 6
: 16,
),
delegate: SliverChildBuilderDelegate(
(context, index) => InkWell(
onTap: () => chatStore.addEmote(emotes[index]),
child: Tooltip(
message: emotes[index].name,
preferBelow: false,
child: Padding(
padding: const EdgeInsets.all(10.0),
child: Center(
child: CachedNetworkImage(
imageUrl: emotes[index].url,
height: emotes[index].height?.toDouble() ?? defaultEmoteSize,
width: emotes[index].width?.toDouble(),
),
itemBuilder: (context, index) => InkWell(
onTap: () => chatStore.addEmote(emotes[index]),
child: Tooltip(
message: emotes[index].name,
preferBelow: false,
child: Padding(
padding: const EdgeInsets.all(5.0),
child: Center(
child: FrostyCachedNetworkImage(
imageUrl: emotes[index].url,
height: emotes[index].height?.toDouble() ?? defaultEmoteSize,
width: emotes[index].width?.toDouble(),
),
),
),
),
childCount: emotes.length,
),
itemCount: emotes.length,
);
}
}

View file

@ -1,15 +1,12 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/button.dart';
import 'package:frosty/widgets/dialog.dart';
import 'package:frosty/widgets/section_header.dart';
import 'package:frosty/widgets/cached_image.dart';
import 'package:provider/provider.dart';
class RecentEmotesPanel extends StatefulWidget {
class RecentEmotesPanel extends StatelessWidget {
final ChatStore chatStore;
const RecentEmotesPanel({
@ -17,68 +14,11 @@ class RecentEmotesPanel extends StatefulWidget {
required this.chatStore,
}) : super(key: key);
@override
State<RecentEmotesPanel> createState() => _RecentEmotesPanelState();
}
class _RecentEmotesPanelState extends State<RecentEmotesPanel> {
Future<void> _showClearDialog() {
return showDialog(
context: context,
builder: (context) => FrostyDialog(
title: 'Clear Recent Emotes',
content: const Text('Are you sure you want to clear your recent emotes?'),
actions: [
Button(
onPressed: () {
setState(widget.chatStore.assetsStore.recentEmotes.clear);
Navigator.pop(context);
},
child: const Text('Yes'),
),
Button(
fill: true,
onPressed: Navigator.of(context).pop,
color: Colors.red.shade700,
child: const Text('Cancel'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Row(
children: [
const SectionHeader(
'Recent Emotes',
padding: EdgeInsets.symmetric(vertical: 5.0, horizontal: 10.0),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 5.0),
height: 25,
child: Button(
padding: EdgeInsets.zero,
onPressed: widget.chatStore.assetsStore.recentEmotes.isEmpty ? null : _showClearDialog,
color: Theme.of(context).colorScheme.secondary,
child: const Text(
'CLEAR',
style: TextStyle(
letterSpacing: 0.8,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
),
if (widget.chatStore.assetsStore.recentEmotes.isEmpty)
if (chatStore.assetsStore.recentEmotes.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: AlertMessage(message: 'No recent emotes'),
@ -94,19 +34,23 @@ class _RecentEmotesPanelState extends State<RecentEmotesPanel> {
),
delegate: SliverChildBuilderDelegate(
(context, index) {
final emote = widget.chatStore.assetsStore.recentEmotes[index];
final validEmotes = [...widget.chatStore.assetsStore.emoteToObject.values, ...widget.chatStore.assetsStore.userEmoteToObject.values];
final matchingEmotes = validEmotes.where((existingEmote) => existingEmote.name == emote.name && existingEmote.type == emote.type);
final emote = chatStore.assetsStore.recentEmotes[index];
final validEmotes = [
...chatStore.assetsStore.emoteToObject.values,
...chatStore.assetsStore.userEmoteToObject.values
];
final matchingEmotes = validEmotes
.where((existingEmote) => existingEmote.name == emote.name && existingEmote.type == emote.type);
return InkWell(
onTap: matchingEmotes.isNotEmpty ? () => widget.chatStore.addEmote(emote) : null,
onTap: matchingEmotes.isNotEmpty ? () => chatStore.addEmote(emote) : null,
child: Tooltip(
message: emote.name,
preferBelow: false,
child: Padding(
padding: const EdgeInsets.all(10.0),
child: Center(
child: CachedNetworkImage(
child: FrostyCachedNetworkImage(
imageUrl: matchingEmotes.isNotEmpty ? matchingEmotes.first.url : emote.url,
color: matchingEmotes.isNotEmpty ? null : const Color.fromRGBO(255, 255, 255, 0.5),
colorBlendMode: matchingEmotes.isNotEmpty ? null : BlendMode.modulate,
@ -118,7 +62,7 @@ class _RecentEmotesPanelState extends State<RecentEmotesPanel> {
),
);
},
childCount: widget.chatStore.assetsStore.recentEmotes.length,
childCount: chatStore.assetsStore.recentEmotes.length,
),
),
],

View file

@ -63,16 +63,14 @@ abstract class ChatAssetsStoreBase with Store {
@readonly
var _userToBTTVBadges = <String, Badge>{};
/// The current index of the emote menu stack.
@observable
var emoteMenuIndex = 0;
/// Whether or not the emote menu is visible.
@observable
var showEmoteMenu = false;
bool isBTTV(Emote emote) {
return emote.type == EmoteType.bttvChannel || emote.type == EmoteType.bttvGlobal || emote.type == EmoteType.bttvShared;
return emote.type == EmoteType.bttvChannel ||
emote.type == EmoteType.bttvGlobal ||
emote.type == EmoteType.bttvShared;
}
bool isFFZ(Emote emote) {
@ -97,8 +95,12 @@ abstract class ChatAssetsStoreBase with Store {
// Retrieve the instance that will allow us to retrieve local search history.
final prefs = await SharedPreferences.getInstance();
_recentEmotes =
prefs.getStringList('recent_emotes')?.map((emoteJson) => Emote.fromJson(jsonDecode(emoteJson))).toList().asObservable() ?? ObservableList<Emote>();
_recentEmotes = prefs
.getStringList('recent_emotes')
?.map((emoteJson) => Emote.fromJson(jsonDecode(emoteJson)))
.toList()
.asObservable() ??
ObservableList<Emote>();
_disposeReaction = autorun((_) {
if (_recentEmotes.length > 48) _recentEmotes.removeLast();
@ -150,7 +152,9 @@ abstract class ChatAssetsStoreBase with Store {
ffzRoomInfo = ffzRoom.item1;
return ffzRoom.item2;
}).catchError(onError),
]).then((assets) => assets.expand((list) => list)).then((emotes) => _emoteToObject = {for (final emote in emotes) emote.name: emote});
])
.then((assets) => assets.expand((list) => list))
.then((emotes) => _emoteToObject = {for (final emote in emotes) emote.name: emote});
@action
Future<void> badgesFuture({
@ -161,10 +165,10 @@ abstract class ChatAssetsStoreBase with Store {
Future.wait([
// Get global badges first, then channel badges to avoid badge conflicts.
// We want the channel badges to override the global badges.
twitchApi
.getBadgesGlobal()
twitchApi.getBadgesGlobal().then((badges) => twitchBadgesToObject.addAll(badges)).then((_) => twitchApi
.getBadgesChannel(id: channelId)
.then((badges) => twitchBadgesToObject.addAll(badges))
.then((_) => twitchApi.getBadgesChannel(id: channelId).then((badges) => twitchBadgesToObject.addAll(badges)).catchError(onError)),
.catchError(onError)),
ffzApi.getBadges().then((badges) => _userToFFZBadges = badges).catchError(onError),
sevenTVApi.getBadges().then((badges) => _userTo7TVBadges = badges).catchError(onError),
bttvApi.getBadges().then((badges) => _userToBTTVBadges = badges).catchError(onError),
@ -176,7 +180,8 @@ abstract class ChatAssetsStoreBase with Store {
required Map<String, String> headers,
required Function onError,
}) async {
final userEmotes = await Future.wait(emoteSets.map((setId) => twitchApi.getEmotesSets(setId: setId, headers: headers).catchError(onError)));
final userEmotes = await Future.wait(
emoteSets.map((setId) => twitchApi.getEmotesSets(setId: setId, headers: headers).catchError(onError)));
for (final emoteSet in userEmotes) {
if (emoteSet.isNotEmpty) {

View file

@ -159,22 +159,6 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store {
});
}
late final _$emoteMenuIndexAtom =
Atom(name: 'ChatAssetsStoreBase.emoteMenuIndex', context: context);
@override
int get emoteMenuIndex {
_$emoteMenuIndexAtom.reportRead();
return super.emoteMenuIndex;
}
@override
set emoteMenuIndex(int value) {
_$emoteMenuIndexAtom.reportWrite(value, super.emoteMenuIndex, () {
super.emoteMenuIndex = value;
});
}
late final _$showEmoteMenuAtom =
Atom(name: 'ChatAssetsStoreBase.showEmoteMenu', context: context);
@ -266,7 +250,6 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store {
@override
String toString() {
return '''
emoteMenuIndex: ${emoteMenuIndex},
showEmoteMenu: ${showEmoteMenu},
bttvEmotes: ${bttvEmotes},
ffzEmotes: ${ffzEmotes},

View file

@ -86,6 +86,21 @@ abstract class ChatStoreBase with Store {
/// Timer used for dismissing the notification.
Timer? _notificationTimer;
/// The current timer for the sleep timer if active.
Timer? sleepTimer;
/// The amount of hours the sleep timer is set to.
@observable
var sleepHours = 0;
/// The amount of minutes the sleep timer is set to.
@observable
var sleepMinutes = 0;
/// The time remaining for the sleep timer.
@observable
var timeRemaining = const Duration();
/// A notification message to display above the chat.
@readonly
String? _notification;
@ -158,7 +173,8 @@ abstract class ChatStoreBase with Store {
if (settings.chatDelay > 0) {
_messageBuffer.add(IRCMessage.createNotice(
message: 'Waiting ${settings.chatDelay.toInt()} ${settings.chatDelay == 1.0 ? 'second' : 'seconds'} due to message delay setting...'));
message:
'Waiting ${settings.chatDelay.toInt()} ${settings.chatDelay == 1.0 ? 'second' : 'seconds'} due to message delay setting...'));
}
connectToChat();
@ -189,8 +205,8 @@ abstract class ChatStoreBase with Store {
// Add a listener to the textfield that will show/hide the autocomplete bar if focused.
// Will also rebuild the autocomplete bar when typing, refreshing the results as the user types.
textController
.addListener(() => _showEmoteAutocomplete = !_showMentionAutocomplete && textFieldFocusNode.hasFocus && textController.text.split(' ').last.isNotEmpty);
textController.addListener(() => _showEmoteAutocomplete =
!_showMentionAutocomplete && textFieldFocusNode.hasFocus && textController.text.split(' ').last.isNotEmpty);
textController.addListener(() {
_showSendButton = textController.text.isNotEmpty;
@ -214,7 +230,9 @@ abstract class ChatStoreBase with Store {
// Filter messages from any blocked users if not a moderator or not the channel owner.
if (!_userState.mod &&
channelName != auth.user.details?.login &&
auth.user.blockedUsers.where((blockedUser) => blockedUser.userLogin == parsedIRCMessage.user).isNotEmpty) continue;
auth.user.blockedUsers.where((blockedUser) => blockedUser.userLogin == parsedIRCMessage.user).isNotEmpty) {
continue;
}
switch (parsedIRCMessage.command) {
case Command.privateMessage:
@ -280,7 +298,9 @@ abstract class ChatStoreBase with Store {
_channel?.sink.add('PONG :tmi.twitch.tv');
return;
} else if (message.contains('Welcome, GLHF!')) {
_messageBuffer.add(IRCMessage.createNotice(message: "Connected to $displayName${regexEnglish.hasMatch(displayName) ? '' : ' ($channelName)'}'s chat!"));
_messageBuffer.add(IRCMessage.createNotice(
message:
"Connected to $displayName${regexEnglish.hasMatch(displayName) ? '' : ' ($channelName)'}'s chat!"));
getAssets();
@ -334,7 +354,8 @@ abstract class ChatStoreBase with Store {
if (_backoffTime > 0) {
// Add notice that chat was disconnected and then wait the backoff time before reconnecting.
final notice = 'Disconnected from chat, waiting $_backoffTime ${_backoffTime == 1 ? 'second' : 'seconds'} before reconnecting...';
final notice =
'Disconnected from chat, waiting $_backoffTime ${_backoffTime == 1 ? 'second' : 'seconds'} before reconnecting...';
_messageBuffer.add(IRCMessage.createNotice(message: notice));
}
@ -451,10 +472,52 @@ abstract class ChatStoreBase with Store {
_notificationTimer = Timer(const Duration(seconds: 2), () => _notification = null);
}
/// Updates the sleep timer with [sleepHours] and [sleepMinutes].
/// Calls [onTimerFinished] when the sleep timer completes.
@action
void updateSleepTimer({required void Function() onTimerFinished}) {
// If hours and minutes are 0, do nothing.
if (sleepHours == 0 && sleepMinutes == 0) return;
// If there is an ongoing timer, cancel it since it'll be replaced.
if (sleepTimer != null) cancelSleepTimer();
// Update the new time remaining
timeRemaining = Duration(hours: sleepHours, minutes: sleepMinutes);
// Reset the hours and minutes in the dropdown buttons.
sleepHours = 0;
sleepMinutes = 0;
// Set a periodic timer that will update the time remaining every second.
sleepTimer = Timer.periodic(
const Duration(seconds: 1),
(timer) {
// If the timer is up, cancel the timer and exit the app.
if (timeRemaining.inSeconds == 0) {
timer.cancel();
onTimerFinished();
return;
}
// Decrement the time remaining.
timeRemaining = Duration(seconds: timeRemaining.inSeconds - 1);
},
);
}
/// Cancels the sleep timer and resets the time remaining.
@action
void cancelSleepTimer() {
sleepTimer?.cancel();
timeRemaining = const Duration();
}
/// Closes and disposes all the channels and controllers used by the store.
void dispose() {
_messageBufferTimer.cancel();
_notificationTimer?.cancel();
sleepTimer?.cancel();
_channel?.sink.close(1001);
_channel = null;

View file

@ -17,6 +17,54 @@ mixin _$ChatStore on ChatStoreBase, Store {
name: 'ChatStoreBase.renderMessages'))
.value;
late final _$sleepHoursAtom =
Atom(name: 'ChatStoreBase.sleepHours', context: context);
@override
int get sleepHours {
_$sleepHoursAtom.reportRead();
return super.sleepHours;
}
@override
set sleepHours(int value) {
_$sleepHoursAtom.reportWrite(value, super.sleepHours, () {
super.sleepHours = value;
});
}
late final _$sleepMinutesAtom =
Atom(name: 'ChatStoreBase.sleepMinutes', context: context);
@override
int get sleepMinutes {
_$sleepMinutesAtom.reportRead();
return super.sleepMinutes;
}
@override
set sleepMinutes(int value) {
_$sleepMinutesAtom.reportWrite(value, super.sleepMinutes, () {
super.sleepMinutes = value;
});
}
late final _$timeRemainingAtom =
Atom(name: 'ChatStoreBase.timeRemaining', context: context);
@override
Duration get timeRemaining {
_$timeRemainingAtom.reportRead();
return super.timeRemaining;
}
@override
set timeRemaining(Duration value) {
_$timeRemainingAtom.reportWrite(value, super.timeRemaining, () {
super.timeRemaining = value;
});
}
late final _$_notificationAtom =
Atom(name: 'ChatStoreBase._notification', context: context);
@ -249,9 +297,34 @@ mixin _$ChatStore on ChatStoreBase, Store {
}
}
@override
void updateSleepTimer({required void Function() onTimerFinished}) {
final _$actionInfo = _$ChatStoreBaseActionController.startAction(
name: 'ChatStoreBase.updateSleepTimer');
try {
return super.updateSleepTimer(onTimerFinished: onTimerFinished);
} finally {
_$ChatStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void cancelSleepTimer() {
final _$actionInfo = _$ChatStoreBaseActionController.startAction(
name: 'ChatStoreBase.cancelSleepTimer');
try {
return super.cancelSleepTimer();
} finally {
_$ChatStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
String toString() {
return '''
sleepHours: ${sleepHours},
sleepMinutes: ${sleepMinutes},
timeRemaining: ${timeRemaining},
expandChat: ${expandChat},
renderMessages: ${renderMessages}
''';

View file

@ -1,10 +1,10 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/screens/channel/chat/details/chat_details.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/widgets/button.dart';
import 'package:frosty/widgets/cached_image.dart';
class ChatBottomBar extends StatelessWidget {
final ChatStore chatStore;
@ -13,14 +13,17 @@ class ChatBottomBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final emoteMenuButton = IconButton(
color: chatStore.assetsStore.showEmoteMenu ? Theme.of(context).colorScheme.secondary : null,
tooltip: 'Emote menu',
icon: const Icon(Icons.emoji_emotions_outlined),
onPressed: () {
FocusScope.of(context).unfocus();
chatStore.assetsStore.showEmoteMenu = !chatStore.assetsStore.showEmoteMenu;
},
final emoteMenuButton = Tooltip(
message: 'Emote menu',
preferBelow: false,
child: IconButton(
color: chatStore.assetsStore.showEmoteMenu ? Theme.of(context).colorScheme.secondary : null,
icon: Icon(chatStore.assetsStore.showEmoteMenu ? Icons.emoji_emotions_rounded : Icons.emoji_emotions_outlined),
onPressed: () {
FocusScope.of(context).unfocus();
chatStore.assetsStore.showEmoteMenu = !chatStore.assetsStore.showEmoteMenu;
},
),
);
return Observer(
@ -30,10 +33,14 @@ class ChatBottomBar extends StatelessWidget {
...chatStore.assetsStore.bttvEmotes,
...chatStore.assetsStore.ffzEmotes,
...chatStore.assetsStore.sevenTVEmotes
].where((emote) => emote.name.toLowerCase().contains(chatStore.textController.text.split(' ').last.toLowerCase())).toList();
]
.where((emote) =>
emote.name.toLowerCase().contains(chatStore.textController.text.split(' ').last.toLowerCase()))
.toList();
final matchingChatters = chatStore.chatDetailsStore.allChatters
.where((chatter) => chatter.contains(chatStore.textController.text.split(' ').last.replaceFirst('@', '').toLowerCase()))
.where((chatter) =>
chatter.contains(chatStore.textController.text.split(' ').last.replaceFirst('@', '').toLowerCase()))
.toList();
return Column(
@ -57,9 +64,9 @@ class ChatBottomBar extends StatelessWidget {
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 5.0),
child: Center(
child: CachedNetworkImage(
child: FrostyCachedNetworkImage(
imageUrl: matchingEmotes[index].url,
fadeInDuration: const Duration(),
useFade: false,
height: matchingEmotes[index].height?.toDouble() ?? defaultEmoteSize,
width: matchingEmotes[index].width?.toDouble(),
),
@ -70,7 +77,9 @@ class ChatBottomBar extends StatelessWidget {
),
),
],
if (chatStore.settings.autocomplete && chatStore.showMentionAutocomplete && matchingChatters.isNotEmpty) ...[
if (chatStore.settings.autocomplete &&
chatStore.showMentionAutocomplete &&
matchingChatters.isNotEmpty) ...[
const Divider(
height: 1.0,
thickness: 1.0,
@ -89,7 +98,8 @@ class ChatBottomBar extends StatelessWidget {
..add('@${matchingChatters[index]} ');
chatStore.textController.text = split.join(' ');
chatStore.textController.selection = TextSelection.fromPosition(TextPosition(offset: chatStore.textController.text.length));
chatStore.textController.selection =
TextSelection.fromPosition(TextPosition(offset: chatStore.textController.text.length));
},
child: Text(matchingChatters[index]),
),
@ -105,12 +115,12 @@ class ChatBottomBar extends StatelessWidget {
chatStore.settings.showVideo &&
MediaQuery.of(context).orientation == Orientation.landscape)
IconButton(
tooltip: 'Send a message',
tooltip: 'Enter a message',
onPressed: () {
chatStore.expandChat = true;
chatStore.textFieldFocusNode.requestFocus();
},
icon: const Icon(Icons.chat),
icon: const Icon(Icons.edit),
)
else
Expanded(
@ -121,7 +131,7 @@ class ChatBottomBar extends StatelessWidget {
maxLines: 5,
enabled: chatStore.auth.isLoggedIn ? true : false,
decoration: InputDecoration(
contentPadding: const EdgeInsets.fromLTRB(15.0, 10.0, 0.0, 10.0),
contentPadding: const EdgeInsets.only(left: 15.0),
prefixIcon: chatStore.settings.emoteMenuButtonOnLeft ? emoteMenuButton : null,
suffixIcon: chatStore.settings.emoteMenuButtonOnLeft ? null : emoteMenuButton,
hintMaxLines: 1,
@ -134,15 +144,18 @@ class ChatBottomBar extends StatelessWidget {
),
),
if (chatStore.showSendButton &&
(chatStore.settings.chatWidth >= 0.3 || chatStore.expandChat || MediaQuery.of(context).orientation == Orientation.portrait))
(chatStore.settings.chatWidth >= 0.3 ||
chatStore.expandChat ||
MediaQuery.of(context).orientation == Orientation.portrait))
IconButton(
tooltip: 'Send',
icon: const Icon(Icons.send),
onPressed: chatStore.auth.isLoggedIn ? () => chatStore.sendMessage(chatStore.textController.text) : null,
icon: const Icon(Icons.send_rounded),
onPressed:
chatStore.auth.isLoggedIn ? () => chatStore.sendMessage(chatStore.textController.text) : null,
)
else
IconButton(
icon: Icon(Icons.adaptive.more),
icon: Icon(Icons.adaptive.more_rounded),
tooltip: 'More',
onPressed: () => showModalBottomSheet(
backgroundColor: Colors.transparent,

View file

@ -20,15 +20,13 @@ class ChatMessage extends StatelessWidget {
@override
Widget build(BuildContext context) {
void onLongPressName() {
void onTapName() {
// Ignore if the message is a recent message in the modal bottom sheet.
if (isModal) return;
// Ignore if long-pressing own username.
if (ircMessage.user == null || ircMessage.user == chatStore.auth.user.details?.login) return;
HapticFeedback.lightImpact();
showModalBottomSheet(
backgroundColor: Colors.transparent,
isScrollControlled: true,
@ -62,12 +60,14 @@ class ChatMessage extends StatelessWidget {
case Command.userState:
// If user is being mentioned in the message, highlight it red.
if (ircMessage.mention == true) color = Colors.red.withOpacity(0.2);
if (chatStore.settings.highlightFirstTimeChatter && ircMessage.tags['first-msg'] == '1') color = Colors.green.withOpacity(0.2);
if (chatStore.settings.highlightFirstTimeChatter && ircMessage.tags['first-msg'] == '1') {
color = Colors.green.withOpacity(0.2);
}
final messageSpan = Text.rich(
TextSpan(
children: ircMessage.generateSpan(
onLongPressName: onLongPressName,
onTapName: onTapName,
style: defaultTextStyle,
assetsStore: chatStore.assetsStore,
emoteScale: chatStore.settings.emoteScale,
@ -84,14 +84,15 @@ class ChatMessage extends StatelessWidget {
final replyUser = ircMessage.tags['reply-parent-display-name'];
final replyBody = ircMessage.tags['reply-parent-msg-body'];
if ((replyUser != null && replyBody != null) || (chatStore.settings.highlightFirstTimeChatter && ircMessage.tags['first-msg'] == '1')) {
if ((replyUser != null && replyBody != null) ||
(chatStore.settings.highlightFirstTimeChatter && ircMessage.tags['first-msg'] == '1')) {
renderMessage = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
replyUser != null && replyBody != null ? Icons.reply : Icons.new_releases_outlined,
replyUser != null && replyBody != null ? Icons.reply_rounded : Icons.new_releases_outlined,
size: defaultBadgeSize * chatStore.settings.badgeScale,
color: defaultTextStyle.color?.withOpacity(0.5),
textDirection: TextDirection.rtl,
@ -102,6 +103,8 @@ class ChatMessage extends StatelessWidget {
? Tooltip(
message: 'Replying to @$replyUser: $replyBody',
preferBelow: false,
triggerMode: TooltipTriggerMode.tap,
showDuration: const Duration(seconds: 5),
child: Text(
'Replying to @$replyUser: $replyBody',
maxLines: 1,
@ -160,7 +163,7 @@ class ChatMessage extends StatelessWidget {
Text.rich(
TextSpan(
children: ircMessage.generateSpan(
onLongPressName: onLongPressName,
onTapName: onTapName,
style: defaultTextStyle,
assetsStore: chatStore.assetsStore,
emoteScale: chatStore.settings.emoteScale,
@ -214,7 +217,7 @@ class ChatMessage extends StatelessWidget {
Text.rich(
TextSpan(
children: ircMessage.generateSpan(
onLongPressName: onLongPressName,
onTapName: onTapName,
style: defaultTextStyle,
assetsStore: chatStore.assetsStore,
emoteScale: chatStore.settings.emoteScale,

View file

@ -1,13 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_message.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/block_report_modal.dart';
import 'package:frosty/widgets/bottom_sheet.dart';
import 'package:frosty/widgets/button.dart';
import 'package:frosty/widgets/modal.dart';
import 'package:frosty/widgets/profile_picture.dart';
import 'package:frosty/widgets/section_header.dart';
@ -32,9 +31,10 @@ class ChatUserModal extends StatefulWidget {
class _ChatUserModalState extends State<ChatUserModal> {
@override
Widget build(BuildContext context) {
final name = regexEnglish.hasMatch(widget.displayName) ? widget.displayName : '${widget.displayName} (${widget.username})';
final name =
regexEnglish.hasMatch(widget.displayName) ? widget.displayName : '${widget.displayName} (${widget.username})';
return FrostyModal(
return FrostyBottomSheet(
child: SizedBox(
height: MediaQuery.of(context).size.height * 0.5,
child: Column(
@ -49,7 +49,6 @@ class _ChatUserModalState extends State<ChatUserModal> {
children: [
Flexible(
child: Tooltip(
preferBelow: false,
message: name,
child: Text(
name,
@ -71,29 +70,26 @@ class _ChatUserModalState extends State<ChatUserModal> {
child: const Text('Reply'),
)
: null,
onLongPress: () {
HapticFeedback.mediumImpact();
showModalBottomSheet(
backgroundColor: Colors.transparent,
context: context,
builder: (context) => BlockReportModal(
authStore: widget.chatStore.auth,
name: name,
userLogin: widget.username,
userId: widget.userId,
),
);
},
onTap: () => showModalBottomSheet(
backgroundColor: Colors.transparent,
context: context,
builder: (context) => BlockReportModal(
authStore: widget.chatStore.auth,
name: name,
userLogin: widget.username,
userId: widget.userId,
),
),
),
const SectionHeader(
'Recent Messages',
'Recent messages',
padding: EdgeInsets.all(10.0),
),
Expanded(
child: Observer(
builder: (context) {
final userMessages = widget.chatStore.messages.reversed.where((message) => message.user == widget.username).toList();
final userMessages =
widget.chatStore.messages.reversed.where((message) => message.user == widget.username).toList();
if (userMessages.isEmpty) {
return const AlertMessage(message: 'No recent messages');

View file

@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/models/stream.dart';
import 'package:frosty/screens/home/top/categories/category_streams.dart';
import 'package:frosty/widgets/profile_picture.dart';
class VideoBar extends StatelessWidget {
final StreamTwitch streamInfo;
final bool showCategory;
final bool tappableCategory;
final Color? titleTextColor;
final Color? subtitleTextColor;
final FontWeight? subtitleTextWeight;
const VideoBar({
Key? key,
required this.streamInfo,
this.showCategory = true,
this.tappableCategory = true,
this.titleTextColor,
this.subtitleTextColor,
this.subtitleTextWeight,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final streamTitle = streamInfo.title.trim();
final category = streamInfo.gameName.isNotEmpty ? streamInfo.gameName : 'No Category';
final streamerName = regexEnglish.hasMatch(streamInfo.userName)
? streamInfo.userName
: '${streamInfo.userName} (${streamInfo.userLogin})';
return Padding(
padding: const EdgeInsets.all(10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ProfilePicture(userLogin: streamInfo.userLogin),
const SizedBox(width: 10.0),
Flexible(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Tooltip(
message: 'Streamer: $streamerName',
showDuration: const Duration(seconds: 3),
child: Text(
streamerName,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16,
color: titleTextColor,
),
),
),
const SizedBox(height: 5.0),
Tooltip(
message: 'Title: ${streamInfo.title.trim()}',
showDuration: const Duration(seconds: 5),
child: Text(
streamTitle,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: subtitleTextColor ?? Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
fontWeight: subtitleTextWeight,
),
),
),
if (showCategory) ...[
const SizedBox(height: 5.0),
InkWell(
onTap: tappableCategory && streamInfo.gameName.isNotEmpty
? () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => CategoryStreams(
categoryName: streamInfo.gameName,
categoryId: streamInfo.gameId,
),
),
)
: null,
child: Tooltip(
message: 'Category: ${streamInfo.gameName.isNotEmpty ? streamInfo.gameName : 'None'}',
showDuration: const Duration(seconds: 3),
child: Text(
category,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: subtitleTextColor ?? Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
fontWeight: subtitleTextWeight,
),
),
),
),
],
],
),
),
],
),
);
}
}

View file

@ -3,96 +3,25 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/main.dart';
import 'package:frosty/screens/channel/chat/details/chat_users_list.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/video/video_bar.dart';
import 'package:frosty/screens/channel/video/video_store.dart';
import 'package:frosty/widgets/button.dart';
import 'package:frosty/widgets/dialog.dart';
import 'package:frosty/widgets/profile_picture.dart';
import 'package:frosty/widgets/bottom_sheet.dart';
import 'package:frosty/widgets/uptime.dart';
import 'package:intl/intl.dart';
/// Creates a widget containing controls which enable interactions with an underlying [Video] widget.
class VideoOverlay extends StatelessWidget {
final VideoStore videoStore;
final void Function() onSettingsPressed;
final ChatStore chatStore;
const VideoOverlay({
Key? key,
required this.videoStore,
required this.onSettingsPressed,
required this.chatStore,
}) : super(key: key);
Future<void> _showSleepTimerDialog(BuildContext context) {
return showDialog(
context: context,
builder: (context) => FrostyDialog(
title: 'Sleep Timer',
content: Observer(
builder: (context) => Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const Icon(Icons.timer),
Text(' ${videoStore.timeRemaining.toString().split('.')[0]}'),
const Spacer(),
IconButton(
tooltip: 'Cancel sleep timer',
onPressed: videoStore.sleepTimer != null && videoStore.sleepTimer!.isActive ? videoStore.cancelSleepTimer : null,
icon: const Icon(Icons.cancel),
),
],
),
Row(
children: [
DropdownButton(
value: videoStore.sleepHours,
items: List.generate(24, (index) => index).map((e) => DropdownMenuItem(value: e, child: Text(e.toString()))).toList(),
onChanged: (int? hours) => videoStore.sleepHours = hours!,
menuMaxHeight: 200,
),
const SizedBox(width: 10.0),
const Text('Hours'),
],
),
Row(
children: [
DropdownButton(
value: videoStore.sleepMinutes,
items: List.generate(60, (index) => index).map((e) => DropdownMenuItem(value: e, child: Text(e.toString()))).toList(),
onChanged: (int? minutes) => videoStore.sleepMinutes = minutes!,
menuMaxHeight: 200,
),
const SizedBox(width: 10.0),
const Text('Minutes'),
],
),
],
),
),
actions: [
Observer(
builder: (context) => Button(
onPressed: videoStore.sleepHours == 0 && videoStore.sleepMinutes == 0
? null
: () => videoStore.updateSleepTimer(
onTimerFinished: () => navigatorKey.currentState?.popUntil((route) => route.isFirst),
),
child: const Text('Set Timer'),
),
),
Button(
onPressed: Navigator.of(context).pop,
fill: true,
color: Colors.red.shade700,
child: const Text('Close'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final orientation = MediaQuery.of(context).orientation;
@ -100,88 +29,77 @@ class VideoOverlay extends StatelessWidget {
final backButton = IconButton(
tooltip: 'Back',
icon: Icon(
Icons.adaptive.arrow_back,
Icons.adaptive.arrow_back_rounded,
color: Colors.white,
),
onPressed: Navigator.of(context).pop,
);
final settingsButton = IconButton(
tooltip: 'Settings',
icon: const Icon(
Icons.settings,
color: Colors.white,
),
onPressed: onSettingsPressed,
);
final chatOverlayButton = Observer(
builder: (_) => IconButton(
tooltip: videoStore.settingsStore.fullScreenChatOverlay ? 'Hide chat overlay' : 'Show chat overlay',
onPressed: () => videoStore.settingsStore.fullScreenChatOverlay = !videoStore.settingsStore.fullScreenChatOverlay,
icon: videoStore.settingsStore.fullScreenChatOverlay ? const Icon(Icons.chat_bubble_outline) : const Icon(Icons.chat_bubble),
onPressed: () =>
videoStore.settingsStore.fullScreenChatOverlay = !videoStore.settingsStore.fullScreenChatOverlay,
icon: videoStore.settingsStore.fullScreenChatOverlay
? const Icon(Icons.chat_rounded)
: const Icon(Icons.chat_outlined),
color: Colors.white,
),
);
final refreshButton = IconButton(
tooltip: 'Refresh',
icon: const Icon(
Icons.refresh,
color: Colors.white,
final refreshButton = Tooltip(
message: 'Refresh',
preferBelow: false,
child: IconButton(
icon: const Icon(
Icons.refresh_rounded,
color: Colors.white,
),
onPressed: videoStore.handleRefresh,
),
onPressed: videoStore.handleRefresh,
);
final fullScreenButton = IconButton(
tooltip: videoStore.settingsStore.fullScreen ? 'Exit fullscreen mode' : 'Enter fullscreen mode',
icon: videoStore.settingsStore.fullScreen
? const Icon(
Icons.fullscreen_exit,
color: Colors.white,
)
: const Icon(
Icons.fullscreen,
color: Colors.white,
),
onPressed: () => videoStore.settingsStore.fullScreen = !videoStore.settingsStore.fullScreen,
final fullScreenButton = Tooltip(
message: videoStore.settingsStore.fullScreen ? 'Exit fullscreen mode' : 'Enter fullscreen mode',
preferBelow: false,
child: IconButton(
icon: Icon(
videoStore.settingsStore.fullScreen ? Icons.fullscreen_exit_rounded : Icons.fullscreen_rounded,
color: Colors.white,
),
onPressed: () => videoStore.settingsStore.fullScreen = !videoStore.settingsStore.fullScreen,
),
);
final sleepTimerButton = IconButton(
tooltip: 'Sleep timer',
icon: const Icon(
Icons.timer,
color: Colors.white,
),
onPressed: () => _showSleepTimerDialog(context),
);
final rotateButton = IconButton(
tooltip: orientation == Orientation.portrait ? 'Enter landscape mode' : 'Exit landscape mode',
icon: const Icon(
Icons.screen_rotation,
color: Colors.white,
),
onPressed: () {
if (orientation == Orientation.portrait) {
if (Platform.isIOS) {
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeRight,
]);
SystemChrome.setPreferredOrientations([]);
final rotateButton = Tooltip(
message: orientation == Orientation.portrait ? 'Enter landscape mode' : 'Exit landscape mode',
preferBelow: false,
child: IconButton(
icon: const Icon(
Icons.screen_rotation_rounded,
color: Colors.white,
),
onPressed: () {
if (orientation == Orientation.portrait) {
if (Platform.isIOS) {
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeRight,
]);
SystemChrome.setPreferredOrientations([]);
} else {
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeRight,
DeviceOrientation.landscapeLeft,
]);
}
} else {
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeRight,
DeviceOrientation.landscapeLeft,
DeviceOrientation.portraitUp,
]);
SystemChrome.setPreferredOrientations([]);
}
} else {
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
]);
SystemChrome.setPreferredOrientations([]);
}
},
},
),
);
final streamInfo = videoStore.streamInfo;
@ -194,7 +112,6 @@ class VideoOverlay extends StatelessWidget {
backButton,
const Spacer(),
if (videoStore.settingsStore.fullScreen && orientation == Orientation.landscape) chatOverlayButton,
settingsButton,
],
),
Align(
@ -212,11 +129,6 @@ class VideoOverlay extends StatelessWidget {
);
}
final streamTitle = videoStore.streamInfo!.title.trim();
final category = videoStore.streamInfo!.gameName.isNotEmpty ? videoStore.streamInfo!.gameName : 'No Category';
final streamerName = regexEnglish.hasMatch(streamInfo.userName) ? streamInfo.userName : '${streamInfo.userName} (${streamInfo.userLogin})';
return Observer(
builder: (context) {
return Stack(
@ -225,72 +137,30 @@ class VideoOverlay extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
backButton,
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
ProfilePicture(
userLogin: streamInfo.userLogin,
radius: 10,
),
const SizedBox(width: 5),
Flexible(
child: Tooltip(
message: streamerName,
child: Text(
streamerName,
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.white,
fontSize: 16,
),
overflow: TextOverflow.ellipsis,
),
),
),
],
),
const SizedBox(height: 5.0),
Tooltip(
message: streamTitle,
child: Text(
streamTitle,
maxLines: orientation == Orientation.portrait ? 1 : 3,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
),
],
if (orientation == Orientation.landscape)
Expanded(
child: VideoBar(
streamInfo: streamInfo,
titleTextColor: Colors.white,
subtitleTextColor: Colors.white,
subtitleTextWeight: FontWeight.w500,
),
),
),
if (videoStore.settingsStore.fullScreen && orientation == Orientation.landscape) chatOverlayButton,
sleepTimerButton,
settingsButton,
],
),
Center(
child: IconButton(
tooltip: videoStore.paused ? 'Play' : 'Pause',
iconSize: 50.0,
icon: videoStore.paused
? const Icon(
Icons.play_arrow,
color: Colors.white,
)
: const Icon(
Icons.pause,
color: Colors.white,
),
onPressed: videoStore.handlePausePlay,
child: Tooltip(
message: videoStore.paused ? 'Play' : 'Pause',
preferBelow: false,
child: IconButton(
iconSize: 50.0,
icon: Icon(
videoStore.paused ? Icons.play_arrow_rounded : Icons.pause_rounded,
color: Colors.white,
),
onPressed: videoStore.handlePausePlay,
),
),
),
Align(
@ -300,62 +170,62 @@ class VideoOverlay extends StatelessWidget {
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(bottom: 8.0, left: 8.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
padding: const EdgeInsets.only(left: 10.0, bottom: 12.0),
child: Row(
children: [
Row(
children: [
const Icon(
Icons.games,
color: Colors.white,
size: 14,
),
const SizedBox(width: 5),
Flexible(
child: Tooltip(
message: category,
preferBelow: false,
child: Text(
category,
style: const TextStyle(
fontWeight: FontWeight.w500,
color: Colors.white,
Tooltip(
message: 'Stream uptime',
preferBelow: false,
child: Row(
children: [
const Padding(
padding: EdgeInsets.all(2.0),
child: Icon(
Icons.circle,
color: Colors.red,
size: 10,
),
),
const SizedBox(width: 3.0),
Uptime(
startTime: streamInfo.startedAt,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
],
),
),
const SizedBox(width: 10),
Tooltip(
message: 'Viewer count',
preferBelow: false,
child: GestureDetector(
onTap: () => showModalBottomSheet(
backgroundColor: Colors.transparent,
isScrollControlled: true,
context: context,
builder: (context) => FrostyBottomSheet(
child: SizedBox(
height: MediaQuery.of(context).size.height * 0.8,
child: GestureDetector(
onTap: FocusScope.of(context).unfocus,
child: ChattersList(
chatDetailsStore: chatStore.chatDetailsStore,
chatStore: chatStore,
userLogin: streamInfo.userLogin,
),
),
overflow: TextOverflow.ellipsis,
),
),
),
],
),
const SizedBox(height: 5),
Row(
children: [
Row(
child: Row(
children: [
const Icon(
Icons.circle,
color: Colors.red,
Icons.visibility,
size: 14,
),
const SizedBox(width: 5),
Uptime(
startTime: streamInfo.startedAt,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
],
),
const SizedBox(width: 10),
Row(
children: [
const Icon(
Icons.people,
color: Colors.white,
size: 14,
),
const SizedBox(width: 5),
Text(
@ -367,19 +237,22 @@ class VideoOverlay extends StatelessWidget {
),
],
),
],
),
),
],
),
),
),
IconButton(
tooltip: 'Picture-in-picture',
icon: const Icon(
Icons.picture_in_picture_alt_rounded,
color: Colors.white,
Tooltip(
message: 'Enter picture-in-picture',
preferBelow: false,
child: IconButton(
icon: const Icon(
Icons.picture_in_picture_alt_rounded,
color: Colors.white,
),
onPressed: videoStore.requestPictureInPicture,
),
onPressed: videoStore.requestPictureInPicture,
),
refreshButton,
if (!videoStore.isIPad) rotateButton,

View file

@ -32,9 +32,6 @@ abstract class VideoStoreBase with Store {
/// The webview controller used for injecting JavaScript to control the webview and video player.
WebViewController? controller;
/// The current timer for the sleep timer if active.
Timer? sleepTimer;
/// The timer that handles hiding the overlay automatically
late Timer _overlayTimer;
@ -69,18 +66,6 @@ abstract class VideoStoreBase with Store {
return NavigationDecision.prevent;
}
/// The amount of hours the sleep timer is set to.
@observable
var sleepHours = 0;
/// The amount of minutes the sleep timer is set to.
@observable
var sleepMinutes = 0;
/// The time remaining for the sleep timer.
@observable
var timeRemaining = const Duration();
/// If the video is currently paused.
///
/// Does not pause or play the video, only used for rendering state of the overlay.
@ -135,8 +120,10 @@ abstract class VideoStoreBase with Store {
Future<void> initVideo() async {
// Add event listeners to notify the JavaScript channels when the video plays and pauses.
try {
controller?.runJavascript('document.getElementsByTagName("video")[0].addEventListener("pause", () => VideoPause.postMessage("video paused"));');
controller?.runJavascript('document.getElementsByTagName("video")[0].addEventListener("playing", () => VideoPlaying.postMessage("video playing"));');
controller?.runJavascript(
'document.getElementsByTagName("video")[0].addEventListener("pause", () => VideoPause.postMessage("video paused"));');
controller?.runJavascript(
'document.getElementsByTagName("video")[0].addEventListener("playing", () => VideoPlaying.postMessage("video playing"));');
} catch (e) {
debugPrint(e.toString());
}
@ -186,17 +173,6 @@ abstract class VideoStoreBase with Store {
}
}
/// Handles toggling "minimal mode" on the overlay.
///
/// "Minimal mode" is when only the channel name is visible on the bottom-left of the overlay.
@action
void handleExpand() {
settingsStore.expandInfo = !settingsStore.expandInfo;
_overlayTimer.cancel();
_overlayTimer = Timer(const Duration(seconds: 5), () => _overlayVisible = false);
}
/// Handles the toggle overlay options.
///
/// The toggle overlay option allows switching between the custom and Twitch's overlay by long-pressing the overlay.
@ -224,47 +200,6 @@ abstract class VideoStoreBase with Store {
updateStreamInfo();
}
/// Updates the sleep timer with [sleepHours] and [sleepMinutes].
/// Calls [onTimerFinished] when the sleep timer completes.
@action
void updateSleepTimer({required void Function() onTimerFinished}) {
// If hours and minutes are 0, do nothing.
if (sleepHours == 0 && sleepMinutes == 0) return;
// If there is an ongoing timer, cancel it since it'll be replaced.
if (sleepTimer != null) cancelSleepTimer();
// Update the new time remaining
timeRemaining = Duration(hours: sleepHours, minutes: sleepMinutes);
// Reset the hours and minutes in the dropdown buttons.
sleepHours = 0;
sleepMinutes = 0;
// Set a periodic timer that will update the time remaining every second.
sleepTimer = Timer.periodic(
const Duration(seconds: 1),
(timer) {
// If the timer is up, cancel the timer and exit the app.
if (timeRemaining.inSeconds == 0) {
timer.cancel();
onTimerFinished();
return;
}
// Decrement the time remaining.
timeRemaining = Duration(seconds: timeRemaining.inSeconds - 1);
},
);
}
/// Cancels the sleep timer and resets the time remaining.
@action
void cancelSleepTimer() {
sleepTimer?.cancel();
timeRemaining = const Duration();
}
/// Play or pause the video depending on the current state of [_paused].
void handlePausePlay() {
try {
@ -304,6 +239,5 @@ abstract class VideoStoreBase with Store {
if (Platform.isIOS) controller?.reload();
_disposeOverlayReaction();
sleepTimer?.cancel();
}
}

View file

@ -17,54 +17,6 @@ mixin _$VideoStore on VideoStoreBase, Store {
name: 'VideoStoreBase.videoUrl'))
.value;
late final _$sleepHoursAtom =
Atom(name: 'VideoStoreBase.sleepHours', context: context);
@override
int get sleepHours {
_$sleepHoursAtom.reportRead();
return super.sleepHours;
}
@override
set sleepHours(int value) {
_$sleepHoursAtom.reportWrite(value, super.sleepHours, () {
super.sleepHours = value;
});
}
late final _$sleepMinutesAtom =
Atom(name: 'VideoStoreBase.sleepMinutes', context: context);
@override
int get sleepMinutes {
_$sleepMinutesAtom.reportRead();
return super.sleepMinutes;
}
@override
set sleepMinutes(int value) {
_$sleepMinutesAtom.reportWrite(value, super.sleepMinutes, () {
super.sleepMinutes = value;
});
}
late final _$timeRemainingAtom =
Atom(name: 'VideoStoreBase.timeRemaining', context: context);
@override
Duration get timeRemaining {
_$timeRemainingAtom.reportRead();
return super.timeRemaining;
}
@override
set timeRemaining(Duration value) {
_$timeRemainingAtom.reportWrite(value, super.timeRemaining, () {
super.timeRemaining = value;
});
}
late final _$_pausedAtom =
Atom(name: 'VideoStoreBase._paused', context: context);
@ -167,17 +119,6 @@ mixin _$VideoStore on VideoStoreBase, Store {
}
}
@override
void handleExpand() {
final _$actionInfo = _$VideoStoreBaseActionController.startAction(
name: 'VideoStoreBase.handleExpand');
try {
return super.handleExpand();
} finally {
_$VideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void handleToggleOverlay() {
final _$actionInfo = _$VideoStoreBaseActionController.startAction(
@ -200,28 +141,6 @@ mixin _$VideoStore on VideoStoreBase, Store {
}
}
@override
void updateSleepTimer({required void Function() onTimerFinished}) {
final _$actionInfo = _$VideoStoreBaseActionController.startAction(
name: 'VideoStoreBase.updateSleepTimer');
try {
return super.updateSleepTimer(onTimerFinished: onTimerFinished);
} finally {
_$VideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void cancelSleepTimer() {
final _$actionInfo = _$VideoStoreBaseActionController.startAction(
name: 'VideoStoreBase.cancelSleepTimer');
try {
return super.cancelSleepTimer();
} finally {
_$VideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void dispose() {
final _$actionInfo = _$VideoStoreBaseActionController.startAction(
@ -236,9 +155,6 @@ mixin _$VideoStore on VideoStoreBase, Store {
@override
String toString() {
return '''
sleepHours: ${sleepHours},
sleepMinutes: ${sleepMinutes},
timeRemaining: ${timeRemaining},
videoUrl: ${videoUrl}
''';
}

View file

@ -93,7 +93,7 @@ class _HomeState extends State<Home> {
actions: [
IconButton(
tooltip: 'Settings',
icon: const Icon(Icons.settings),
icon: const Icon(Icons.settings_rounded),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
@ -104,30 +104,23 @@ class _HomeState extends State<Home> {
],
),
body: SafeArea(
child: Column(
children: [
Expanded(
child: Observer(
builder: (_) => IndexedStack(
index: _homeStore.selectedIndex,
children: [
if (_authStore.isLoggedIn)
StreamsList(
listType: ListType.followed,
scrollController: _homeStore.followedScrollController,
),
TopSection(
homeStore: _homeStore,
),
Search(
scrollController: _homeStore.searchScrollController,
),
],
child: Observer(
builder: (_) => IndexedStack(
index: _homeStore.selectedIndex,
children: [
if (_authStore.isLoggedIn)
StreamsList(
listType: ListType.followed,
scrollController: _homeStore.followedScrollController,
),
TopSection(
homeStore: _homeStore,
),
),
const Divider(height: 1.0, thickness: 1.0),
],
Search(
scrollController: _homeStore.searchScrollController,
),
],
),
),
),
bottomNavigationBar: Observer(
@ -135,20 +128,24 @@ class _HomeState extends State<Home> {
unselectedFontSize: 12.0,
selectedFontSize: 12.0,
type: BottomNavigationBarType.fixed,
selectedLabelStyle: const TextStyle(fontWeight: FontWeight.w500),
unselectedLabelStyle: const TextStyle(fontWeight: FontWeight.w500),
items: [
if (_authStore.isLoggedIn)
const BottomNavigationBarItem(
icon: Icon(Icons.favorite),
BottomNavigationBarItem(
icon: _homeStore.selectedIndex == 0
? const Icon(Icons.favorite_rounded)
: const Icon(Icons.favorite_border_rounded),
label: 'Following',
tooltip: 'Followed streams',
),
const BottomNavigationBarItem(
icon: Icon(Icons.arrow_upward),
icon: Icon(Icons.arrow_upward_rounded),
label: 'Top',
tooltip: 'Top streams and categories',
),
const BottomNavigationBarItem(
icon: Icon(Icons.search),
icon: Icon(Icons.search_rounded),
label: 'Search',
tooltip: 'Search for channels and categories',
),

View file

@ -51,42 +51,42 @@ abstract class HomeStoreBase with Store {
// Use different logic if logged in/out since there will be one less tab when logged out.
if (authStore.isLoggedIn) {
if (index == 0) {
if (index == 0 && followedScrollController.hasClients) {
// If on the followed tab and tapping the followed tab, scroll to the top.
followedScrollController.animateTo(
0.0,
duration: duration,
curve: Curves.easeOutCubic,
curve: Curves.easeOut,
);
} else if (index == 1) {
} else if (index == 1 && topSectionScrollControllers[topSectionCurrentIndex].hasClients) {
// If on the top section, scroll to the top of the tab based on the current top tab.
topSectionScrollControllers[topSectionCurrentIndex].animateTo(
0.0,
duration: duration,
curve: Curves.easeOutCubic,
curve: Curves.easeOut,
);
} else {
} else if (searchScrollController.hasClients) {
// If on the search tab and tapping the search tab, scroll to the top.
searchScrollController.animateTo(
0.0,
duration: duration,
curve: Curves.easeOutCubic,
curve: Curves.easeOut,
);
}
} else {
if (index == 0) {
if (index == 0 && topSectionScrollControllers[topSectionCurrentIndex].hasClients) {
// If on the top section, scroll to the top of the tab based on the current top tab.
topSectionScrollControllers[topSectionCurrentIndex].animateTo(
0.0,
duration: duration,
curve: Curves.easeOutCubic,
curve: Curves.easeOut,
);
} else {
} else if (searchScrollController.hasClients) {
// If on the search tab and tapping the search tab, scroll to the top.
searchScrollController.animateTo(
0.0,
duration: duration,
curve: Curves.easeOutCubic,
curve: Curves.easeOut,
);
}
}

View file

@ -7,6 +7,7 @@ import 'package:frosty/screens/home/search/search_results_channels.dart';
import 'package:frosty/screens/home/search/search_store.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/list_tile.dart';
import 'package:frosty/widgets/section_header.dart';
import 'package:provider/provider.dart';
@ -45,12 +46,12 @@ class _SearchState extends State<Search> {
focusNode: _searchStore.textFieldFocusNode,
autocorrect: false,
decoration: InputDecoration(
contentPadding: const EdgeInsets.all(0.0),
contentPadding: EdgeInsets.zero,
prefixIcon: const Icon(Icons.search_rounded),
hintText: 'Find a channel or category',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchStore.textFieldFocusNode.hasFocus || _searchStore.searchText.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
icon: const Icon(Icons.close_rounded),
tooltip: _searchStore.searchText.isEmpty ? 'Cancel' : 'Clear',
onPressed: () {
if (_searchStore.searchText.isEmpty) _searchStore.textFieldFocusNode.unfocus();
@ -69,10 +70,7 @@ class _SearchState extends State<Search> {
builder: (context) {
if (_searchStore.textEditingController.text.isEmpty) {
if (_searchStore.searchHistory.isEmpty) {
return const AlertMessage(
message: 'No recent searches',
icon: Icons.search_off,
);
return const AlertMessage(message: 'No recent searches');
}
return Column(
@ -87,19 +85,22 @@ class _SearchState extends State<Search> {
controller: widget.scrollController,
children: _searchStore.searchHistory
.mapIndexed(
(index, searchTerm) => ListTile(
leading: const Icon(Icons.history),
title: Text(searchTerm),
trailing: IconButton(
tooltip: 'Remove',
icon: const Icon(Icons.cancel),
onPressed: () => _searchStore.searchHistory.removeAt(index),
(index, searchTerm) => FrostyListTile(
leading: const Icon(Icons.history_rounded),
title: searchTerm,
trailing: Tooltip(
message: 'Remove',
preferBelow: false,
child: IconButton(
icon: const Icon(Icons.close_rounded),
onPressed: () => _searchStore.searchHistory.removeAt(index),
),
),
onTap: () {
_searchStore.textEditingController.text = searchTerm;
_searchStore.handleQuery(searchTerm);
_searchStore.textEditingController.selection =
TextSelection.fromPosition(TextPosition(offset: _searchStore.textEditingController.text.length));
_searchStore.textEditingController.selection = TextSelection.fromPosition(
TextPosition(offset: _searchStore.textEditingController.text.length));
},
),
)

View file

@ -32,10 +32,7 @@ class SearchResultsCategories extends StatelessWidget {
return const SliverToBoxAdapter(
child: SizedBox(
height: 100.0,
child: AlertMessage(
message: 'Failed to get categories',
icon: Icons.error,
),
child: AlertMessage(message: 'Failed to get categories'),
),
);
case FutureStatus.fulfilled:
@ -45,10 +42,7 @@ class SearchResultsCategories extends StatelessWidget {
return const SliverToBoxAdapter(
child: SizedBox(
height: 100.0,
child: AlertMessage(
message: 'Failed to get categories',
icon: Icons.error,
),
child: AlertMessage(message: 'Failed to get categories'),
),
);
}

View file

@ -8,8 +8,10 @@ import 'package:frosty/screens/home/search/search_store.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/animate_scale.dart';
import 'package:frosty/widgets/block_report_modal.dart';
import 'package:frosty/widgets/list_tile.dart';
import 'package:frosty/widgets/loading_indicator.dart';
import 'package:frosty/widgets/profile_picture.dart';
import 'package:frosty/widgets/uptime.dart';
import 'package:mobx/mobx.dart';
class SearchResultsChannels extends StatefulWidget {
@ -44,10 +46,7 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
);
} catch (error) {
final snackBar = SnackBar(
content: AlertMessage(
message: error.toString(),
icon: Icons.error,
),
content: AlertMessage(message: error.toString()),
behavior: SnackBarBehavior.floating,
);
@ -72,79 +71,77 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
return const SliverToBoxAdapter(
child: SizedBox(
height: 100.0,
child: AlertMessage(
message: 'Failed to get channels',
icon: Icons.error,
),
child: AlertMessage(message: 'Failed to get channels'),
),
);
case FutureStatus.fulfilled:
final results = (future.result as List<ChannelQuery>)
.where((channel) => !widget.searchStore.authStore.user.blockedUsers.map((blockedUser) => blockedUser.userId).contains(channel.id));
final results = (future.result as List<ChannelQuery>).where((channel) => !widget
.searchStore.authStore.user.blockedUsers
.map((blockedUser) => blockedUser.userId)
.contains(channel.id));
return SliverList(
delegate: SliverChildListDelegate.fixed([
...results.map(
(channel) {
final displayName =
regexEnglish.hasMatch(channel.displayName) ? channel.displayName : '${channel.displayName} (${channel.broadcasterLogin})';
delegate: SliverChildListDelegate.fixed(
[
...results.map(
(channel) {
final displayName = regexEnglish.hasMatch(channel.displayName)
? channel.displayName
: '${channel.displayName} (${channel.broadcasterLogin})';
return AnimateScale(
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => VideoChat(
userId: channel.id,
userName: channel.displayName,
userLogin: channel.broadcasterLogin,
return AnimateScale(
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => VideoChat(
userId: channel.id,
userName: channel.displayName,
userLogin: channel.broadcasterLogin,
),
),
),
),
onLongPress: () {
HapticFeedback.lightImpact();
onLongPress: () {
HapticFeedback.lightImpact();
showModalBottomSheet(
backgroundColor: Colors.transparent,
context: context,
builder: (context) => BlockReportModal(
authStore: widget.searchStore.authStore,
name: displayName,
userLogin: channel.broadcasterLogin,
userId: channel.id,
),
);
},
child: ListTile(
title: Text(displayName),
leading: ProfilePicture(userLogin: channel.broadcasterLogin),
trailing: channel.isLive
? ClipRRect(
borderRadius: BorderRadius.circular(5.0),
child: Container(
color: const Color(0xFFF44336),
padding: const EdgeInsets.all(10.0),
child: const Text(
'LIVE',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.white,
showModalBottomSheet(
backgroundColor: Colors.transparent,
context: context,
builder: (context) => BlockReportModal(
authStore: widget.searchStore.authStore,
name: displayName,
userLogin: channel.broadcasterLogin,
userId: channel.id,
),
);
},
child: FrostyListTile(
isThreeLine: false,
title: displayName,
leading: ProfilePicture(userLogin: channel.broadcasterLogin),
subtitle: channel.isLive
? Row(
children: [
const Icon(
Icons.circle,
color: Colors.red,
size: 10,
),
),
),
)
: null,
subtitle:
channel.isLive ? Text('Uptime: ${DateTime.now().difference(DateTime.parse(channel.startedAt)).toString().split('.')[0]}') : null,
),
);
},
),
ListTile(
title: Text('Go to channel "${widget.query}"'),
onTap: () => _handleSearch(context, widget.query),
trailing: Icon(Icons.adaptive.arrow_forward),
)
]),
const SizedBox(width: 5),
Uptime(startTime: channel.startedAt),
],
)
: null,
),
);
},
),
FrostyListTile(
title: 'Go to channel "${widget.query}"',
onTap: () => _handleSearch(context, widget.query),
trailing: const Icon(Icons.chevron_right_rounded),
)
],
),
);
}
},

View file

@ -0,0 +1,172 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/main.dart';
import 'package:frosty/models/stream.dart';
import 'package:frosty/screens/channel/channel.dart';
import 'package:frosty/screens/channel/video/video_bar.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/widgets/animate_scale.dart';
import 'package:frosty/widgets/block_report_modal.dart';
import 'package:frosty/widgets/cached_image.dart';
import 'package:frosty/widgets/loading_indicator.dart';
import 'package:frosty/widgets/uptime.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
class LargeStreamCard extends StatelessWidget {
final StreamTwitch streamInfo;
final bool showThumbnail;
final bool showCategory;
const LargeStreamCard({
Key? key,
required this.streamInfo,
required this.showThumbnail,
this.showCategory = true,
}) : super(key: key);
@override
Widget build(BuildContext context) {
// Get a new URL for the thumbnail every 5 minutes so that the image is updated on refresh.
// This method adds a random value to the end of the URL to override the cached image.
final time = DateTime.now();
final cacheUrlExtension = time.day.toString() + time.hour.toString() + (time.minute ~/ 5).toString();
// Calculate the width and height of the thumbnail based on the device width and the stream card size setting.
// Constraint the resolution to 1920x1080 since that's the max resolution of the Twitch API.
final size = MediaQuery.of(context).size;
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
final thumbnailWidth = min((size.width * pixelRatio) ~/ 1, 1920);
final thumbnailHeight = min((thumbnailWidth * (9 / 16)).toInt(), 1080);
final thumbnail = ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(10)),
child: Stack(
alignment: Alignment.bottomLeft,
children: [
Container(
foregroundDecoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: [0.8, 1.0],
colors: [
Colors.transparent,
Colors.black,
],
),
),
child: AspectRatio(
aspectRatio: 16 / 9,
child: FrostyCachedNetworkImage(
imageUrl:
streamInfo.thumbnailUrl.replaceFirst('-{width}x{height}', '-${thumbnailWidth}x$thumbnailHeight') +
cacheUrlExtension,
placeholder: (context, url) => const ColoredBox(color: lightGray, child: LoadingIndicator()),
),
),
),
Padding(
padding: const EdgeInsets.only(left: 10.0, bottom: 12.0),
child: Row(
children: [
Tooltip(
message: 'Stream uptime',
preferBelow: false,
child: Row(
children: [
const Padding(
padding: EdgeInsets.all(2.0),
child: Icon(
Icons.circle,
color: Colors.red,
size: 10,
),
),
const SizedBox(width: 3.0),
Uptime(
startTime: streamInfo.startedAt,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
],
),
),
const SizedBox(width: 10.0),
Tooltip(
message: 'Viewer count',
preferBelow: false,
child: Row(
children: [
const Icon(
Icons.visibility,
size: 14,
color: Colors.white,
),
const SizedBox(width: 5),
Text(
NumberFormat().format(streamInfo.viewerCount),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
],
),
)
],
),
),
],
),
);
final streamerName = regexEnglish.hasMatch(streamInfo.userName)
? streamInfo.userName
: '${streamInfo.userName} (${streamInfo.userLogin})';
return AnimateScale(
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => VideoChat(
userId: streamInfo.userId,
userName: streamInfo.userName,
userLogin: streamInfo.userLogin,
),
),
),
onLongPress: () {
HapticFeedback.mediumImpact();
showModalBottomSheet(
backgroundColor: Colors.transparent,
context: context,
builder: (context) => BlockReportModal(
authStore: context.read<AuthStore>(),
name: streamerName,
userLogin: streamInfo.userLogin,
userId: streamInfo.userId,
),
);
},
child: Padding(
padding: EdgeInsets.symmetric(vertical: showThumbnail ? 10.0 : 5.0, horizontal: 15.0),
child: Column(
children: [
if (showThumbnail) thumbnail,
VideoBar(
streamInfo: streamInfo,
showCategory: showCategory,
),
],
),
),
);
}
}

View file

@ -1,15 +1,16 @@
import 'dart:math';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/main.dart';
import 'package:frosty/models/stream.dart';
import 'package:frosty/screens/channel/channel.dart';
import 'package:frosty/screens/home/top/categories/category_streams.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/widgets/animate_scale.dart';
import 'package:frosty/widgets/block_report_modal.dart';
import 'package:frosty/widgets/cached_image.dart';
import 'package:frosty/widgets/loading_indicator.dart';
import 'package:frosty/widgets/profile_picture.dart';
import 'package:frosty/widgets/uptime.dart';
@ -19,17 +20,13 @@ import 'package:provider/provider.dart';
/// A tappable card widget that displays a stream's thumbnail and details.
class StreamCard extends StatelessWidget {
final StreamTwitch streamInfo;
final bool showUptime;
final bool showThumbnail;
final bool large;
final bool showCategory;
const StreamCard({
Key? key,
required this.streamInfo,
required this.showUptime,
required this.showThumbnail,
required this.large,
this.showCategory = true,
}) : super(key: key);
@ -44,84 +41,55 @@ class StreamCard extends StatelessWidget {
// Constraint the resolution to 1920x1080 since that's the max resolution of the Twitch API.
final size = MediaQuery.of(context).size;
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
final thumbnailWidth = min((size.width * pixelRatio) ~/ (large ? 1 : 3), 1920);
final thumbnailWidth = min((size.width * pixelRatio) ~/ 3, 1920);
final thumbnailHeight = min((thumbnailWidth * (9 / 16)).toInt(), 1080);
final image = AspectRatio(
final thumbnail = AspectRatio(
aspectRatio: 16 / 9,
child: CachedNetworkImage(
imageUrl: streamInfo.thumbnailUrl.replaceFirst('-{width}x{height}', '-${thumbnailWidth}x$thumbnailHeight') + cacheUrlExtension,
placeholder: (context, url) => const LoadingIndicator(),
useOldImageOnUrlChange: true,
child: FrostyCachedNetworkImage(
imageUrl: streamInfo.thumbnailUrl.replaceFirst('-{width}x{height}', '-${thumbnailWidth}x$thumbnailHeight') +
cacheUrlExtension,
placeholder: (context, url) => const ColoredBox(color: lightGray, child: LoadingIndicator()),
),
);
final thumbnail = large
? Container(
foregroundDecoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black,
],
),
),
child: image,
)
: image;
final streamerName = regexEnglish.hasMatch(streamInfo.userName)
? streamInfo.userName
: '${streamInfo.userName} (${streamInfo.userLogin})';
final streamerName = regexEnglish.hasMatch(streamInfo.userName) ? streamInfo.userName : '${streamInfo.userName} (${streamInfo.userLogin})';
const subFontSize = 14.0;
final subFontSize = large ? 16.0 : 14.0;
final fontColor = large ? Colors.white : DefaultTextStyle.of(context).style.color;
final fontColor = DefaultTextStyle.of(context).style.color;
final imageSection = ClipRRect(
borderRadius: large ? const BorderRadius.all(Radius.circular(10.0)) : const BorderRadius.all(Radius.circular(5.0)),
child: showUptime
? Stack(
alignment: AlignmentDirectional.bottomEnd,
children: [
thumbnail,
if (large)
Padding(
padding: const EdgeInsets.all(10.0),
child: Uptime(
startTime: streamInfo.startedAt,
style: TextStyle(
fontSize: 16.0,
color: Colors.white.withOpacity(0.8),
),
),
)
else
Container(
padding: const EdgeInsets.all(2.0),
decoration: const BoxDecoration(
color: Color.fromRGBO(0, 0, 0, 0.5),
borderRadius: BorderRadius.all(
Radius.circular(3.0),
),
),
margin: const EdgeInsets.all(2.0),
child: Uptime(
startTime: streamInfo.startedAt,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
)
],
borderRadius: const BorderRadius.all(Radius.circular(5.0)),
child: Stack(
alignment: AlignmentDirectional.bottomEnd,
children: [
thumbnail,
Container(
padding: const EdgeInsets.all(2.0),
decoration: const BoxDecoration(
color: Color.fromRGBO(0, 0, 0, 0.5),
borderRadius: BorderRadius.all(
Radius.circular(3.0),
),
),
margin: const EdgeInsets.all(2.0),
child: Uptime(
startTime: streamInfo.startedAt,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
)
: thumbnail,
);
],
));
final streamInfoSection = Padding(
padding: large ? const EdgeInsets.all(10.0) : const EdgeInsets.only(left: 10.0),
padding: const EdgeInsets.only(left: 10.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -134,16 +102,16 @@ class StreamCard extends StatelessWidget {
const SizedBox(width: 5.0),
Flexible(
child: Tooltip(
message: streamerName,
message: 'Streamer: $streamerName',
preferBelow: false,
child: Text(
streamerName,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: large ? 20.0 : 16.0,
fontSize: 16.0,
fontWeight: FontWeight.w600,
color: fontColor,
),
overflow: TextOverflow.ellipsis,
),
),
),
@ -151,7 +119,7 @@ class StreamCard extends StatelessWidget {
),
const SizedBox(height: 5.0),
Tooltip(
message: streamInfo.title.trim(),
message: 'Title: ${streamInfo.title.trim()}',
preferBelow: false,
padding: const EdgeInsets.all(10.0),
child: Text(
@ -178,7 +146,7 @@ class StreamCard extends StatelessWidget {
)
: null,
child: Tooltip(
message: streamInfo.gameName,
message: 'Category: ${streamInfo.gameName.isNotEmpty ? streamInfo.gameName : 'None'}',
preferBelow: false,
child: Text(
streamInfo.gameName.isNotEmpty ? streamInfo.gameName : 'No Category',
@ -230,24 +198,19 @@ class StreamCard extends StatelessWidget {
},
child: Padding(
padding: EdgeInsets.symmetric(vertical: 10.0, horizontal: showThumbnail ? 15.0 : 5.0),
child: large
? Stack(
alignment: AlignmentDirectional.bottomStart,
children: [if (showThumbnail) imageSection, streamInfoSection],
)
: Row(
children: [
if (showThumbnail)
Flexible(
flex: 1,
child: imageSection,
),
Flexible(
flex: 2,
child: streamInfoSection,
),
],
child: Row(
children: [
if (showThumbnail)
Flexible(
flex: 1,
child: imageSection,
),
Flexible(
flex: 2,
child: streamInfoSection,
),
],
),
),
);
}

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/screens/home/stream_list/large_stream_card.dart';
import 'package:frosty/screens/home/stream_list/stream_card.dart';
import 'package:frosty/screens/home/stream_list/stream_list_store.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
@ -66,10 +67,7 @@ class _StreamsListState extends State<StreamsList> with AutomaticKeepAliveClient
if (_listStore.error != null) {
final snackBar = SnackBar(
content: AlertMessage(
message: _listStore.error!,
icon: Icons.error,
),
content: AlertMessage(message: _listStore.error!),
behavior: SnackBarBehavior.floating,
);
@ -82,17 +80,15 @@ class _StreamsListState extends State<StreamsList> with AutomaticKeepAliveClient
Widget? statusWidget;
if (_listStore.error != null) {
statusWidget = AlertMessage(
message: _listStore.error!,
icon: Icons.error,
);
statusWidget = AlertMessage(message: _listStore.error!);
}
if (_listStore.streams.isEmpty) {
if (_listStore.isLoading && _listStore.error == null) {
statusWidget = const LoadingIndicator(subtitle: 'Loading streams...');
} else {
statusWidget = AlertMessage(message: widget.listType == ListType.followed ? 'No followed streams' : 'No top streams');
statusWidget = AlertMessage(
message: widget.listType == ListType.followed ? 'No followed streams' : 'No top streams');
}
}
@ -119,12 +115,17 @@ class _StreamsListState extends State<StreamsList> with AutomaticKeepAliveClient
_listStore.getStreams();
}
return Observer(
builder: (context) => StreamCard(
streamInfo: _listStore.streams[index],
showThumbnail: context.read<SettingsStore>().showThumbnails,
large: context.read<SettingsStore>().largeStreamCard,
showUptime: context.read<SettingsStore>().showThumbnailUptime,
),
builder: (context) => context.read<SettingsStore>().largeStreamCard
? LargeStreamCard(
key: ValueKey(_listStore.streams[index].userId),
streamInfo: _listStore.streams[index],
showThumbnail: context.read<SettingsStore>().showThumbnails,
)
: StreamCard(
key: ValueKey(_listStore.streams[index].userId),
streamInfo: _listStore.streams[index],
showThumbnail: context.read<SettingsStore>().showThumbnails,
),
);
},
);

View file

@ -56,10 +56,7 @@ class _CategoriesState extends State<Categories> with AutomaticKeepAliveClientMi
if (_categoriesStore.error != null) {
final snackBar = SnackBar(
content: AlertMessage(
message: _categoriesStore.error!,
icon: Icons.error,
),
content: AlertMessage(message: _categoriesStore.error!),
behavior: SnackBarBehavior.floating,
);
@ -72,10 +69,7 @@ class _CategoriesState extends State<Categories> with AutomaticKeepAliveClientMi
Widget? statusWidget;
if (_categoriesStore.error != null) {
statusWidget = AlertMessage(
message: _categoriesStore.error!,
icon: Icons.error,
);
statusWidget = AlertMessage(message: _categoriesStore.error!);
}
if (_categoriesStore.categories.isEmpty) {
@ -110,6 +104,7 @@ class _CategoriesState extends State<Categories> with AutomaticKeepAliveClientMi
_categoriesStore.getCategories();
}
return CategoryCard(
key: ValueKey(_categoriesStore.categories[index].id),
category: _categoriesStore.categories[index],
);
},

View file

@ -1,8 +1,8 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:frosty/main.dart';
import 'package:frosty/models/category.dart';
import 'package:frosty/screens/home/top/categories/category_streams.dart';
import 'package:frosty/widgets/animate_scale.dart';
import 'package:frosty/widgets/cached_image.dart';
import 'package:frosty/widgets/loading_indicator.dart';
/// A tappable card widget that displays a category's box art and name under.
@ -22,7 +22,7 @@ class CategoryCard extends StatelessWidget {
final artWidth = (size.width * pixelRatio) ~/ 3;
final artHeight = (artWidth * (4 / 3)).toInt();
return AnimateScale(
return InkWell(
onTap: () => Navigator.push(
context,
MaterialPageRoute(
@ -38,12 +38,13 @@ class CategoryCard extends StatelessWidget {
children: [
Expanded(
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(5.0)),
borderRadius: const BorderRadius.all(Radius.circular(10.0)),
child: AspectRatio(
aspectRatio: 3 / 4,
child: CachedNetworkImage(
imageUrl: category.boxArtUrl.replaceRange(category.boxArtUrl.lastIndexOf('-') + 1, null, '${artWidth}x$artHeight.jpg'),
placeholder: (context, url) => const LoadingIndicator(),
child: FrostyCachedNetworkImage(
imageUrl: category.boxArtUrl
.replaceRange(category.boxArtUrl.lastIndexOf('-') + 1, null, '${artWidth}x$artHeight.jpg'),
placeholder: (context, url) => const ColoredBox(color: lightGray, child: LoadingIndicator()),
),
),
),

View file

@ -1,14 +1,16 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/main.dart';
import 'package:frosty/models/category.dart';
import 'package:frosty/screens/home/stream_list/large_stream_card.dart';
import 'package:frosty/screens/home/stream_list/stream_card.dart';
import 'package:frosty/screens/home/stream_list/stream_list_store.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/cached_image.dart';
import 'package:frosty/widgets/loading_indicator.dart';
import 'package:frosty/widgets/scroll_to_top_button.dart';
import 'package:provider/provider.dart';
@ -57,10 +59,7 @@ class _CategoryStreamsState extends State<CategoryStreams> {
if (_listStore.error != null) {
final snackBar = SnackBar(
content: AlertMessage(
message: _listStore.error!,
icon: Icons.error,
),
content: AlertMessage(message: _listStore.error!),
behavior: SnackBarBehavior.floating,
);
@ -78,6 +77,10 @@ class _CategoryStreamsState extends State<CategoryStreams> {
controller: _listStore.scrollController,
slivers: [
SliverAppBar(
leading: IconButton(
icon: Icon(Icons.adaptive.arrow_back_rounded),
onPressed: Navigator.of(context).pop,
),
stretch: true,
pinned: true,
expandedHeight: MediaQuery.of(context).size.height / 3,
@ -100,13 +103,14 @@ class _CategoryStreamsState extends State<CategoryStreams> {
),
builder: (context, AsyncSnapshot<CategoriesTwitch> snapshot) {
return snapshot.hasData
? CachedNetworkImage(
? FrostyCachedNetworkImage(
imageUrl: snapshot.data!.data.first.boxArtUrl.replaceRange(
snapshot.data!.data.first.boxArtUrl.lastIndexOf('-') + 1,
null,
'${artWidth}x$artHeight.jpg',
),
placeholder: (context, url) => const LoadingIndicator(),
placeholder: (context, url) =>
const ColoredBox(color: lightGray, child: LoadingIndicator()),
color: const Color.fromRGBO(255, 255, 255, 0.5),
colorBlendMode: BlendMode.modulate,
fit: BoxFit.cover,
@ -119,10 +123,7 @@ class _CategoryStreamsState extends State<CategoryStreams> {
if (_listStore.error != null)
SliverFillRemaining(
hasScrollBody: false,
child: AlertMessage(
message: _listStore.error!,
icon: Icons.error,
),
child: AlertMessage(message: _listStore.error!),
)
else if (_listStore.streams.isEmpty)
if (_listStore.isLoading && _listStore.error == null)
@ -149,13 +150,17 @@ class _CategoryStreamsState extends State<CategoryStreams> {
_listStore.getStreams();
}
return Observer(
builder: (context) => StreamCard(
streamInfo: _listStore.streams[index],
showUptime: context.read<SettingsStore>().showThumbnailUptime,
showThumbnail: context.read<SettingsStore>().showThumbnails,
large: context.read<SettingsStore>().largeStreamCard,
showCategory: false,
),
builder: (context) => context.read<SettingsStore>().largeStreamCard
? LargeStreamCard(
streamInfo: _listStore.streams[index],
showThumbnail: context.read<SettingsStore>().showThumbnails,
showCategory: false,
)
: StreamCard(
streamInfo: _listStore.streams[index],
showThumbnail: context.read<SettingsStore>().showThumbnails,
showCategory: false,
),
);
},
childCount: _listStore.streams.length,
@ -168,9 +173,11 @@ class _CategoryStreamsState extends State<CategoryStreams> {
child: Observer(
builder: (context) => AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
child: _listStore.showJumpButton ? ScrollToTopButton(scrollController: _listStore.scrollController!) : null,
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: _listStore.showJumpButton
? ScrollToTopButton(scrollController: _listStore.scrollController!)
: null,
),
),
),

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:frosty/main.dart';
import 'package:frosty/screens/home/home_store.dart';
import 'package:frosty/screens/home/stream_list/stream_list_store.dart';
import 'package:frosty/screens/home/stream_list/streams_list.dart';
@ -28,23 +29,19 @@ class _TopSectionState extends State<TopSection> with SingleTickerProviderStateM
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TabBar(
controller: _tabBarController,
indicatorColor: const Color(0xff9146ff),
labelStyle: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16.0,
),
indicatorSize: TabBarIndicatorSize.label,
indicatorColor: purple,
tabs: const [
Tab(
child: Text(
'Streams',
style: TextStyle(fontWeight: FontWeight.w600),
),
),
Tab(
child: Text(
'Categories',
style: TextStyle(fontWeight: FontWeight.w600),
),
),
Tab(child: Text('Streams')),
Tab(child: Text('Categories')),
],
),
Expanded(

View file

@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:frosty/screens/settings/account/blocked_users.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/widgets/settings_tile_route.dart';
import 'package:frosty/widgets/button.dart';
import 'package:frosty/widgets/dialog.dart';
import 'package:frosty/widgets/list_tile.dart';
class AccountOptions extends StatelessWidget {
final AuthStore authStore;
const AccountOptions({Key? key, required this.authStore}) : super(key: key);
Future<void> _showLogoutDialog(BuildContext context) {
return showDialog(
context: context,
builder: (context) => FrostyDialog(
title: 'Log Out',
message: 'Are you sure you want to log out?',
actions: [
Button(
onPressed: () {
authStore.logout();
Navigator.pop(context);
Navigator.pop(context);
},
child: const Text('Yes'),
),
Button(
onPressed: Navigator.of(context).pop,
color: Colors.grey,
child: const Text('Cancel'),
)
],
),
);
}
@override
Widget build(BuildContext context) {
return ListView(
children: [
SettingsTileRoute(
leading: const Icon(Icons.block_rounded),
title: 'Blocked',
child: BlockedUsers(
authStore: authStore,
),
),
FrostyListTile(
leading: const Icon(Icons.logout_rounded),
title: 'Log out',
trailing: const Icon(Icons.chevron_right_rounded),
onTap: () => _showLogoutDialog(context),
)
],
);
}
}

View file

@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/button.dart';
import 'package:frosty/widgets/list_tile.dart';
class BlockedUsers extends StatelessWidget {
final AuthStore authStore;
const BlockedUsers({
Key? key,
required this.authStore,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return RefreshIndicator(
onRefresh: () async {
HapticFeedback.lightImpact();
await authStore.user.refreshBlockedUsers(headers: authStore.headersTwitch);
},
child: Observer(
builder: (context) {
if (authStore.user.blockedUsers.isEmpty) {
return const Center(
child: AlertMessage(
message: 'No blocked users',
),
);
}
return ListView(
children: authStore.user.blockedUsers.map(
(blockedUser) {
final displayName = regexEnglish.hasMatch(blockedUser.displayName)
? blockedUser.displayName
: '${blockedUser.displayName} (${blockedUser.userLogin})';
return FrostyListTile(
title: displayName,
trailing: Button(
color: Colors.red,
onPressed: () => authStore.showBlockDialog(
context,
targetUser: displayName,
targetUserId: blockedUser.userId,
),
child: const Text('Unblock'),
),
);
},
).toList(),
);
},
),
);
}
}

View file

@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/settings/account/account_options.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/widgets/settings_tile_route.dart';
import 'package:frosty/widgets/app_bar.dart';
import 'package:frosty/widgets/button.dart';
import 'package:frosty/widgets/dialog.dart';
import 'package:frosty/widgets/list_tile.dart';
import 'package:frosty/widgets/profile_picture.dart';
import 'package:simple_icons/simple_icons.dart';
import 'package:webview_flutter/webview_flutter.dart';
class ProfileCard extends StatelessWidget {
final AuthStore authStore;
const ProfileCard({Key? key, required this.authStore}) : super(key: key);
Future<void> _showLoginDialog(BuildContext context) {
return showDialog(
context: context,
builder: (context) => FrostyDialog(
title: 'Log In',
content: Column(
children: [
Button(
padding: const EdgeInsets.symmetric(horizontal: 30.0, vertical: 15.0),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return Scaffold(
appBar: const FrostyAppBar(
title: Text('Connect with Twitch'),
),
body: WebView(
initialUrl: authStore.loginUri.toString(),
navigationDelegate: authStore.handleNavigation,
javascriptMode: JavascriptMode.unrestricted,
),
);
},
),
),
icon: const Icon(SimpleIcons.twitch),
child: const Text(
'Connect with Twitch',
style: TextStyle(
fontSize: 16,
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(height: 20.0),
const Center(
child: Text(
'Or',
style: TextStyle(fontWeight: FontWeight.w600),
),
),
const SizedBox(height: 20.0),
TextField(
autocorrect: false,
textAlign: TextAlign.center,
decoration: const InputDecoration(
hintText: 'Token',
),
onSubmitted: (token) {
authStore.login(token: token);
Navigator.pop(context);
},
)
],
),
),
);
}
@override
Widget build(BuildContext context) {
return Observer(
builder: (context) {
if (authStore.error != null) {
return FrostyListTile(
leading: const Icon(
Icons.error_outline_rounded,
color: Colors.red,
),
title: 'Failed to connect',
trailing: Button(
onPressed: authStore.init,
padding: const EdgeInsets.symmetric(horizontal: 15.0),
child: const Text('Reconnect'),
),
);
}
if (authStore.isLoggedIn && authStore.user.details != null) {
return SettingsTileRoute(
leading: ProfilePicture(
userLogin: authStore.user.details!.login,
radius: 12,
),
title: authStore.user.details!.displayName,
child: AccountOptions(authStore: authStore),
);
}
return FrostyListTile(
leading: const Icon(Icons.no_accounts_rounded),
title: 'Anonymous',
subtitle: const Text('Tap to log in and enable the ability to chat, view followed streams, and more.'),
trailing: const SizedBox(
height: double.infinity,
child: Icon(Icons.chevron_right_rounded),
),
onTap: () => _showLoginDialog(context),
);
},
);
}
}

View file

@ -1,265 +0,0 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/block_button.dart';
import 'package:frosty/widgets/button.dart';
import 'package:frosty/widgets/dialog.dart';
import 'package:webview_flutter/webview_flutter.dart';
class AccountSettings extends StatelessWidget {
final SettingsStore settingsStore;
final AuthStore authStore;
const AccountSettings({
Key? key,
required this.settingsStore,
required this.authStore,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Observer(
builder: (context) => ExpansionTile(
leading: const Icon(Icons.account_circle),
title: const Text(
'Account',
style: TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.bold,
),
),
children: [
ProfileCard(authStore: authStore),
if (authStore.isLoggedIn) ...[
ListTile(
title: const Text('Blocked users'),
trailing: Icon(Icons.adaptive.arrow_forward),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => BlockedUsers(
authStore: authStore,
),
),
),
),
],
],
),
);
}
}
class ProfileCard extends StatelessWidget {
final AuthStore authStore;
const ProfileCard({Key? key, required this.authStore}) : super(key: key);
Future<void> _showLoginDialog(BuildContext context) {
return showDialog(
context: context,
builder: (context) => FrostyDialog(
title: 'Log In',
content: Column(
children: [
Button(
padding: const EdgeInsets.symmetric(horizontal: 30.0, vertical: 15.0),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return Scaffold(
appBar: AppBar(
title: const Text('Connect with Twitch'),
),
body: WebView(
initialUrl: authStore.loginUri.toString(),
navigationDelegate: authStore.handleNavigation,
javascriptMode: JavascriptMode.unrestricted,
),
);
},
),
),
icon: Image.asset(
'assets/icons/TwitchGlitchWhite.png',
height: 25,
),
child: const Text(
'Connect with Twitch',
style: TextStyle(
fontSize: 16,
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(height: 20.0),
const Center(
child: Text(
'Or',
style: TextStyle(fontWeight: FontWeight.w600),
),
),
const SizedBox(height: 20.0),
TextField(
autocorrect: false,
textAlign: TextAlign.center,
decoration: const InputDecoration(
hintText: 'Token',
),
onSubmitted: (token) {
authStore.login(token: token);
Navigator.pop(context);
},
)
],
),
),
);
}
Future<void> _showLogoutDialog(BuildContext context) {
return showDialog(
context: context,
builder: (context) => FrostyDialog(
title: 'Log Out',
content: const Text('Are you sure you want to log out?'),
actions: [
Button(
onPressed: () {
authStore.logout();
Navigator.pop(context);
},
child: const Text('Yes'),
),
Button(
fill: true,
onPressed: Navigator.of(context).pop,
color: Colors.red.shade700,
child: const Text('Cancel'),
)
],
),
);
}
@override
Widget build(BuildContext context) {
return Observer(
builder: (context) {
if (authStore.error != null) {
return ListTile(
leading: const Icon(Icons.error),
title: const Text('Failed to connect'),
trailing: Button(
onPressed: authStore.init,
padding: const EdgeInsets.symmetric(horizontal: 15.0),
child: const Text('Try Again'),
),
);
}
if (authStore.isLoggedIn && authStore.user.details != null) {
return ListTile(
leading: CircleAvatar(
foregroundImage: CachedNetworkImageProvider(authStore.user.details!.profileImageUrl),
),
title: Text(
authStore.user.details!.displayName,
style: const TextStyle(fontWeight: FontWeight.w600),
),
trailing: Button(
onPressed: () => _showLogoutDialog(context),
icon: const Icon(Icons.logout_outlined),
padding: const EdgeInsets.symmetric(horizontal: 15.0),
fill: true,
color: Colors.red.shade700,
child: const Text('Log Out'),
),
);
}
return ListTile(
isThreeLine: true,
leading: const Icon(
Icons.no_accounts,
size: 40,
),
title: const Text('Anonymous User'),
subtitle: const Text('Log in to chat, view followed streams, and more.'),
trailing: Button(
color: Theme.of(context).colorScheme.secondary,
onPressed: () => _showLoginDialog(context),
fill: true,
padding: const EdgeInsets.symmetric(horizontal: 15.0),
icon: const Icon(Icons.login),
child: const Text('Log In'),
),
);
},
);
}
}
class BlockedUsers extends StatelessWidget {
final AuthStore authStore;
const BlockedUsers({
Key? key,
required this.authStore,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Blocked Users'),
),
body: RefreshIndicator(
onRefresh: () async {
HapticFeedback.lightImpact();
await authStore.user.refreshBlockedUsers(headers: authStore.headersTwitch);
},
child: Observer(
builder: (context) {
if (authStore.user.blockedUsers.isEmpty) {
return const Center(
child: AlertMessage(
message: 'No blocked users',
),
);
}
return ListView(
children: authStore.user.blockedUsers.map(
(user) {
final displayName = regexEnglish.hasMatch(user.displayName) ? user.displayName : '${user.displayName} (${user.userLogin})';
return ListTile(
title: Tooltip(
preferBelow: false,
message: displayName,
child: Text(
displayName,
overflow: TextOverflow.ellipsis,
),
),
trailing: BlockButton(
authStore: authStore,
targetUser: displayName,
targetUserId: user.userId,
),
);
},
).toList(),
);
},
),
),
);
}
}

View file

@ -1,10 +1,13 @@
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/screens/settings/widgets/settings_list_select.dart';
import 'package:frosty/screens/settings/widgets/settings_list_slider.dart';
import 'package:frosty/screens/settings/widgets/settings_list_switch.dart';
import 'package:frosty/widgets/cached_image.dart';
import 'package:frosty/widgets/section_header.dart';
class ChatSettings extends StatefulWidget {
@ -25,231 +28,18 @@ class _ChatSettingsState extends State<ChatSettings> {
const timestamps = ['Disabled', '12-hour', '24-hour'];
const sectionPadding = EdgeInsets.only(left: 16.0, bottom: 5.0, top: 30.0);
final settingsStore = widget.settingsStore;
return Observer(
builder: (context) => ExpansionTile(
expandedCrossAxisAlignment: CrossAxisAlignment.start,
childrenPadding: const EdgeInsets.symmetric(vertical: 10.0),
leading: const Icon(Icons.chat),
title: const Text(
'Chat',
style: TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.bold,
),
),
builder: (context) => ListView(
children: [
ListTile(
title: Row(
children: [
const Text('Message delay'),
const Spacer(),
Text('${settingsStore.chatDelay.toInt()} ${settingsStore.chatDelay == 1.0 ? 'second' : 'seconds'}'),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Slider.adaptive(
value: settingsStore.chatDelay,
min: 0.0,
max: 30.0,
divisions: 30,
onChanged: (newValue) => settingsStore.chatDelay = newValue,
),
Text('Adds a delay before each message is rendered in chat. ${Platform.isIOS ? '15 seconds is recommended for iOS.' : ''}'),
const SizedBox(height: 15),
],
),
),
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Prevent sleep in chat-only mode'),
subtitle: const Text('Requires restarting the chat in order to take effect.'),
value: settingsStore.chatOnlyPreventSleep,
onChanged: !settingsStore.showVideo ? (newValue) => settingsStore.chatOnlyPreventSleep = newValue : null,
),
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Autocomplete'),
subtitle: const Text('Shows a bar that suggests matching emotes and mentions while typing.'),
value: settingsStore.autocomplete,
onChanged: settingsStore.showBottomBar ? (newValue) => settingsStore.autocomplete = newValue : null,
),
const SectionHeader(
'Layout',
padding: sectionPadding,
showDivider: true,
),
SwitchListTile.adaptive(
title: const Text('Bottom bar'),
value: settingsStore.showBottomBar,
onChanged: (newValue) => settingsStore.showBottomBar = newValue,
),
SwitchListTile.adaptive(
title: const Text('Emote menu button on left side'),
value: settingsStore.emoteMenuButtonOnLeft,
onChanged: (newValue) => settingsStore.emoteMenuButtonOnLeft = newValue,
),
SwitchListTile.adaptive(
title: const Text('Landscape chat on left side'),
value: settingsStore.landscapeChatLeftSide,
onChanged: (newValue) => settingsStore.landscapeChatLeftSide = newValue,
),
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Force vertical chat in landscape mode'),
subtitle: const Text('Note: this option is intended for tablets and other larger displays.'),
value: settingsStore.landscapeForceVerticalChat,
onChanged: (newValue) => settingsStore.landscapeForceVerticalChat = newValue,
),
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Notifications on bottom'),
subtitle: const Text('Shows notifications (e.g., "Message copied") on the bottom of the chat.'),
value: settingsStore.chatNotificationsOnBottom,
onChanged: (newValue) => settingsStore.chatNotificationsOnBottom = newValue,
),
ListTile(
isThreeLine: true,
title: const Text('Landscape fill cutout side'),
subtitle: const Text('Overrides and fills the available space in the display cutout/notch.'),
trailing: DropdownButton(
value: settingsStore.landscapeCutout,
onChanged: (LandscapeCutoutType? newValue) => settingsStore.landscapeCutout = newValue!,
items: LandscapeCutoutType.values
.map((LandscapeCutoutType value) => DropdownMenuItem(
value: value,
child: Text(landscapeCutouts[value.index]),
))
.toList(),
),
),
const SizedBox(height: 15.0),
ListTile(
title: Row(
children: [
const Text('Chat width'),
const Spacer(),
Text('${(settingsStore.chatWidth * 100).toStringAsFixed(0)}%'),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Slider.adaptive(
value: settingsStore.chatWidth,
min: 0.2,
max: 0.6,
divisions: 8,
onChanged: (newValue) => settingsStore.chatWidth = newValue,
),
const Text('Sets the width of the chat in fullscreen and theater mode.'),
const SizedBox(height: 15),
],
),
),
const SizedBox(height: 15.0),
ListTile(
title: Row(
children: [
const Text('Chat overlay opacity'),
const Spacer(),
Text('${(settingsStore.fullScreenChatOverlayOpacity * 100).toStringAsFixed(0)}%'),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Slider.adaptive(
value: settingsStore.fullScreenChatOverlayOpacity,
min: 0.0,
max: 1.0,
divisions: 10,
onChanged: (newValue) => settingsStore.fullScreenChatOverlayOpacity = newValue,
),
const Text('Sets the opacity (transparency) of the overlay chat in fullscreen mode.'),
const SizedBox(height: 15),
],
),
),
const SectionHeader(
'Message Appearance',
padding: sectionPadding,
showDivider: true,
),
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Readable name colors'),
subtitle: const Text('Adjusts the lightness value of overly bright or dark names.'),
value: settingsStore.useReadableColors,
onChanged: (newValue) => settingsStore.useReadableColors = newValue,
),
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Show deleted messages'),
subtitle: const Text('Restores the original message of deleted messages.'),
value: settingsStore.showDeletedMessages,
onChanged: (newValue) => settingsStore.showDeletedMessages = newValue,
),
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Message dividers'),
subtitle: const Text('Shows a subtle divider between each message.'),
value: settingsStore.showChatMessageDividers,
onChanged: (newValue) => settingsStore.showChatMessageDividers = newValue,
),
ListTile(
isThreeLine: true,
title: const Text('Message timestamps'),
subtitle: const Text('Shows timestamps for when a message was sent.'),
trailing: DropdownButton(
value: settingsStore.timestampType,
onChanged: (TimestampType? newTimestamp) => settingsStore.timestampType = newTimestamp!,
items: TimestampType.values
.map((TimestampType value) => DropdownMenuItem(
value: value,
child: Text(timestamps[value.index]),
))
.toList(),
),
),
const SectionHeader(
'Alerts',
padding: sectionPadding,
showDivider: true,
),
SwitchListTile.adaptive(
title: const Text('Highlight first time chatters'),
value: settingsStore.highlightFirstTimeChatter,
onChanged: (newValue) => settingsStore.highlightFirstTimeChatter = newValue,
),
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Show notices'),
subtitle: const Text('Shows notices such as subs and re-subs, announcements, and raids.'),
value: settingsStore.showUserNotices,
onChanged: (newValue) => settingsStore.showUserNotices = newValue,
),
const SectionHeader(
'Message Sizing',
padding: sectionPadding,
showDivider: true,
),
const SectionHeader('Message sizing'),
ExpansionTile(
title: const Text('Preview'),
title: const Text('Preview', style: TextStyle(fontWeight: FontWeight.w500)),
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(20.0),
margin: const EdgeInsets.all(10.0),
decoration: BoxDecoration(
border: Border.all(color: Colors.deepPurple),
borderRadius: BorderRadius.circular(15.0),
),
padding: const EdgeInsets.all(16),
child: DefaultTextStyle(
style: DefaultTextStyle.of(context).style.copyWith(fontSize: settingsStore.fontSize),
child: Column(
@ -260,8 +50,9 @@ class _ChatSettingsState extends State<ChatSettings> {
children: [
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: CachedNetworkImage(
imageUrl: 'https://static-cdn.jtvnw.net/badges/v1/bbbe0db0-a598-423e-86d0-f9fb98ca1933/3',
child: FrostyCachedNetworkImage(
imageUrl:
'https://static-cdn.jtvnw.net/badges/v1/bbbe0db0-a598-423e-86d0-f9fb98ca1933/3',
height: defaultBadgeSize * settingsStore.badgeScale,
width: defaultBadgeSize * settingsStore.badgeScale,
),
@ -269,7 +60,7 @@ class _ChatSettingsState extends State<ChatSettings> {
const TextSpan(text: ' Badge and emote preview. '),
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: CachedNetworkImage(
child: FrostyCachedNetworkImage(
imageUrl: 'https://static-cdn.jtvnw.net/emoticons/v2/425618/default/dark/3.0',
height: defaultEmoteSize * settingsStore.emoteScale,
width: defaultEmoteSize * settingsStore.emoteScale,
@ -295,86 +86,164 @@ class _ChatSettingsState extends State<ChatSettings> {
),
],
),
const SizedBox(height: 10.0),
ListTile(
title: Row(
children: [
const Text('Badge scale'),
const Spacer(),
Text('${settingsStore.badgeScale.toStringAsFixed(2)}x'),
],
),
subtitle: Slider.adaptive(
value: settingsStore.badgeScale,
min: 0.25,
max: 3.0,
divisions: 11,
onChanged: (newValue) => settingsStore.badgeScale = newValue,
),
SettingsListSlider(
title: 'Badge scale',
trailing: '${settingsStore.badgeScale.toStringAsFixed(2)}x',
value: settingsStore.badgeScale,
min: 0.25,
max: 3.0,
divisions: 11,
onChanged: (newValue) => settingsStore.badgeScale = newValue,
),
ListTile(
title: Row(
children: [
const Text('Emote scale'),
const Spacer(),
Text('${settingsStore.emoteScale.toStringAsFixed(2)}x'),
],
),
subtitle: Slider.adaptive(
value: settingsStore.emoteScale,
min: 0.25,
max: 3.0,
divisions: 11,
onChanged: (newValue) => settingsStore.emoteScale = newValue,
),
SettingsListSlider(
title: 'Emote scale',
trailing: '${settingsStore.emoteScale.toStringAsFixed(2)}x',
value: settingsStore.emoteScale,
min: 0.25,
max: 3.0,
divisions: 11,
onChanged: (newValue) => settingsStore.emoteScale = newValue,
),
ListTile(
title: Row(
children: [
const Text('Message scale'),
const Spacer(),
Text('${settingsStore.messageScale.toStringAsFixed(2)}x'),
],
),
subtitle: Slider.adaptive(
value: settingsStore.messageScale,
min: 0.5,
max: 2.0,
divisions: 6,
onChanged: (newValue) => settingsStore.messageScale = newValue,
),
SettingsListSlider(
title: 'Message scale',
trailing: '${settingsStore.messageScale.toStringAsFixed(2)}x',
value: settingsStore.messageScale,
min: 0.5,
max: 2.0,
divisions: 6,
onChanged: (newValue) => settingsStore.messageScale = newValue,
),
ListTile(
title: Row(
children: [
const Text('Message spacing'),
const Spacer(),
Text(settingsStore.messageSpacing.toStringAsFixed(0).toString()),
],
),
subtitle: Slider.adaptive(
value: settingsStore.messageSpacing,
min: 0.0,
max: 30.0,
divisions: 6,
onChanged: (newValue) => settingsStore.messageSpacing = newValue,
),
SettingsListSlider(
title: 'Message spacing',
trailing: '${settingsStore.messageSpacing.toStringAsFixed(0)}px',
value: settingsStore.messageSpacing,
max: 30.0,
divisions: 6,
onChanged: (newValue) => settingsStore.messageSpacing = newValue,
),
ListTile(
title: Row(
children: [
const Text('Font size'),
const Spacer(),
Text(settingsStore.fontSize.toInt().toString()),
],
),
subtitle: Slider.adaptive(
value: settingsStore.fontSize,
min: 5,
max: 20,
divisions: 15,
onChanged: (newValue) => settingsStore.fontSize = newValue,
),
SettingsListSlider(
title: 'Font size',
trailing: settingsStore.fontSize.toInt().toString(),
value: settingsStore.fontSize,
min: 5,
max: 20,
divisions: 15,
onChanged: (newValue) => settingsStore.fontSize = newValue,
),
const SectionHeader('Message appearance'),
SettingsListSwitch(
title: 'Use readable name colors',
subtitle: const Text('Adjusts the lightness value of overly bright and dark names.'),
value: settingsStore.useReadableColors,
onChanged: (newValue) => settingsStore.useReadableColors = newValue,
),
SettingsListSwitch(
title: 'Show deleted messages',
subtitle: const Text('Restores the original message of deleted messages.'),
value: settingsStore.showDeletedMessages,
onChanged: (newValue) => settingsStore.showDeletedMessages = newValue,
),
SettingsListSwitch(
title: 'Show message dividers',
value: settingsStore.showChatMessageDividers,
onChanged: (newValue) => settingsStore.showChatMessageDividers = newValue,
),
SettingsListSelect(
title: 'Message timestamps',
selectedOption: timestamps[settingsStore.timestampType.index],
options: timestamps,
onChanged: (newValue) => settingsStore.timestampType = TimestampType.values[timestamps.indexOf(newValue)],
),
const SectionHeader('Delay'),
SettingsListSlider(
title: 'Message delay',
trailing: '${settingsStore.chatDelay.toInt()}s',
subtitle:
'Adds a delay (in seconds) before each message is rendered in chat. ${Platform.isIOS ? '15 seconds is recommended for iOS.' : ''}',
value: settingsStore.chatDelay,
max: 30.0,
divisions: 30,
onChanged: (newValue) => settingsStore.chatDelay = newValue,
),
const SectionHeader('Alerts'),
SettingsListSwitch(
title: 'Highlight first time chatters',
value: settingsStore.highlightFirstTimeChatter,
onChanged: (newValue) => settingsStore.highlightFirstTimeChatter = newValue,
),
SettingsListSwitch(
title: 'Show notices',
subtitle: const Text('Shows notices such as subs and re-subs, announcements, and raids.'),
value: settingsStore.showUserNotices,
onChanged: (newValue) => settingsStore.showUserNotices = newValue,
),
const SectionHeader('Layout'),
SettingsListSwitch(
title: 'Show bottom bar',
value: settingsStore.showBottomBar,
onChanged: (newValue) => settingsStore.showBottomBar = newValue,
),
SettingsListSwitch(
title: 'Move emote menu button left',
subtitle: const Text('Places the emote menu button on the left side to avoid accidental presses.'),
value: settingsStore.emoteMenuButtonOnLeft,
onChanged: (newValue) => settingsStore.emoteMenuButtonOnLeft = newValue,
),
SettingsListSwitch(
title: 'Move notifications to bottom',
value: settingsStore.chatNotificationsOnBottom,
onChanged: (newValue) => settingsStore.chatNotificationsOnBottom = newValue,
),
const SectionHeader('Landscape mode'),
SettingsListSwitch(
title: 'Move chat left',
value: settingsStore.landscapeChatLeftSide,
onChanged: (newValue) => settingsStore.landscapeChatLeftSide = newValue,
),
SettingsListSwitch(
title: 'Force vertical chat',
subtitle: const Text('Intended for tablets and other larger displays.'),
value: settingsStore.landscapeForceVerticalChat,
onChanged: (newValue) => settingsStore.landscapeForceVerticalChat = newValue,
),
SettingsListSelect(
title: 'Fill notch side',
subtitle: 'Overrides and fills the available space in devices with a display notch.',
selectedOption: landscapeCutouts[settingsStore.landscapeCutout.index],
options: landscapeCutouts,
onChanged: (newValue) =>
settingsStore.landscapeCutout = LandscapeCutoutType.values[landscapeCutouts.indexOf(newValue)],
),
SettingsListSlider(
title: 'Chat width',
trailing: '${(settingsStore.chatWidth * 100).toStringAsFixed(0)}%',
value: settingsStore.chatWidth,
min: 0.2,
max: 0.6,
divisions: 8,
onChanged: (newValue) => settingsStore.chatWidth = newValue,
),
SettingsListSlider(
title: 'Chat overlay opacity',
trailing: '${(settingsStore.fullScreenChatOverlayOpacity * 100).toStringAsFixed(0)}%',
subtitle: 'Sets the opacity (transparency) of the overlay chat in fullscreen mode.',
value: settingsStore.fullScreenChatOverlayOpacity,
divisions: 10,
onChanged: (newValue) => settingsStore.fullScreenChatOverlayOpacity = newValue,
),
const SectionHeader('Sleep'),
SettingsListSwitch(
title: 'Prevent sleep in chat-only mode',
subtitle: const Text('Requires restarting the chat in order to take effect.'),
value: settingsStore.chatOnlyPreventSleep,
onChanged: (newValue) => settingsStore.chatOnlyPreventSleep = newValue,
),
const SectionHeader('Autocomplete'),
SettingsListSwitch(
title: 'Show autocomplete bar',
subtitle: const Text('Shows a bar containing matching emotes and mentions while typing.'),
value: settingsStore.autocomplete,
onChanged: (newValue) => settingsStore.autocomplete = newValue,
),
],
),

View file

@ -1,6 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/screens/settings/widgets/settings_list_select.dart';
import 'package:frosty/screens/settings/widgets/settings_list_switch.dart';
import 'package:frosty/widgets/section_header.dart';
class GeneralSettings extends StatelessWidget {
final SettingsStore settingsStore;
@ -12,50 +15,29 @@ class GeneralSettings extends StatelessWidget {
const themes = ['System', 'Light', 'Dark', 'Black'];
return Observer(
builder: (context) => ExpansionTile(
leading: const Icon(Icons.settings),
title: const Text(
'General',
style: TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.bold,
),
),
builder: (context) => ListView(
children: [
ListTile(
title: const Text('Theme'),
trailing: DropdownButton(
value: settingsStore.themeType,
onChanged: (ThemeType? newTheme) => settingsStore.themeType = newTheme!,
items: ThemeType.values
.map((ThemeType value) => DropdownMenuItem(
value: value,
child: Text(themes[value.index]),
))
.toList(),
),
const SectionHeader('Display'),
SettingsListSelect(
title: 'Theme',
selectedOption: themes[settingsStore.themeType.index],
options: themes,
onChanged: (newTheme) => settingsStore.themeType = ThemeType.values[themes.indexOf(newTheme)],
),
SwitchListTile.adaptive(
title: const Text('Large stream card'),
const SectionHeader('Stream card'),
SettingsListSwitch(
title: 'Use large stream card',
value: settingsStore.largeStreamCard,
onChanged: (newValue) => settingsStore.largeStreamCard = newValue,
),
SwitchListTile.adaptive(
title: const Text('Stream card thumbnails'),
SettingsListSwitch(
title: 'Show thumbnail',
value: settingsStore.showThumbnails,
onChanged: (newValue) => settingsStore.showThumbnails = newValue,
),
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Stream uptime on thumbnails'),
subtitle: const Text('Shows the uptime of the stream in the HH:MM:SS format.'),
value: settingsStore.showThumbnailUptime,
onChanged: settingsStore.showThumbnails ? (newValue) => settingsStore.showThumbnailUptime = newValue : null,
),
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Launch URLs in external browser'),
subtitle: const Text('Opens links in the default external browser.'),
const SectionHeader('Links'),
SettingsListSwitch(
title: 'Open links in external browser',
value: settingsStore.launchUrlExternal,
onChanged: (newValue) => settingsStore.launchUrlExternal = newValue,
),

View file

@ -5,9 +5,11 @@ import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/screens/settings/widgets/settings_list_switch.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/button.dart';
import 'package:frosty/widgets/dialog.dart';
import 'package:frosty/widgets/list_tile.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:url_launcher/url_launcher.dart';
@ -30,7 +32,7 @@ class _OtherSettingsState extends State<OtherSettings> {
context: context,
builder: (context) => FrostyDialog(
title: 'Reset All Settings',
content: const Text('Are you sure you want to reset all settings?'),
message: 'Are you sure you want to reset all settings?',
actions: [
Button(
onPressed: () {
@ -50,9 +52,8 @@ class _OtherSettingsState extends State<OtherSettings> {
child: const Text('Yes'),
),
Button(
fill: true,
onPressed: Navigator.of(context).pop,
color: Colors.red.shade700,
color: Colors.grey,
child: const Text('Cancel'),
),
],
@ -62,20 +63,11 @@ class _OtherSettingsState extends State<OtherSettings> {
@override
Widget build(BuildContext context) {
return ExpansionTile(
childrenPadding: const EdgeInsets.only(bottom: 10.0),
leading: const Icon(Icons.help),
title: const Text(
'Other',
style: TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.bold,
),
),
return Column(
children: [
ListTile(
leading: const Icon(Icons.info),
title: const Text('About Frosty'),
FrostyListTile(
leading: const Icon(Icons.info_outline_rounded),
title: 'About Frosty',
onTap: () async {
final packageInfo = await PackageInfo.fromPlatform();
@ -91,23 +83,44 @@ class _OtherSettingsState extends State<OtherSettings> {
);
},
),
ListTile(
leading: const Icon(Icons.launch),
title: const Text('Changelog'),
FrostyListTile(
leading: const Icon(Icons.launch_rounded),
title: 'FAQ',
onTap: () => launchUrl(Uri.parse('https://www.frostyapp.io/#faq'),
mode: widget.settingsStore.launchUrlExternal ? LaunchMode.externalApplication : LaunchMode.inAppWebView),
),
FrostyListTile(
leading: const Icon(Icons.launch_rounded),
title: 'Changelog',
onTap: () => launchUrl(Uri.parse('https://github.com/tommyxchow/frosty/releases'),
mode: widget.settingsStore.launchUrlExternal ? LaunchMode.externalApplication : LaunchMode.inAppWebView),
),
ListTile(
leading: const Icon(Icons.launch),
title: const Text('FAQ'),
onTap: () => launchUrl(Uri.parse('https://github.com/tommyxchow/frosty#faq'),
mode: widget.settingsStore.launchUrlExternal ? LaunchMode.externalApplication : LaunchMode.inAppWebView),
FrostyListTile(
leading: const Icon(Icons.delete_outline_rounded),
title: 'Clear image cache',
onTap: () async {
HapticFeedback.mediumImpact();
await DefaultCacheManager().emptyCache();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: AlertMessage(message: 'Image cache cleared'),
behavior: SnackBarBehavior.floating,
),
);
},
),
FrostyListTile(
leading: const Icon(Icons.restore_rounded),
title: 'Reset settings',
onTap: () => _showConfirmDialog(context),
),
Observer(
builder: (_) => SwitchListTile.adaptive(
title: const Text('Send anonymous crash logs'),
isThreeLine: true,
subtitle: const Text('Help improve Frosty by sending anonymous crash logs through Sentry.'),
builder: (_) => SettingsListSwitch(
title: 'Send anonymous crash logs',
subtitle: const Text('Help improve Frosty by sending anonymous crash logs through Sentry.io.'),
value: widget.settingsStore.sendCrashLogs,
onChanged: (newValue) {
if (newValue == true) {
@ -119,41 +132,6 @@ class _OtherSettingsState extends State<OtherSettings> {
},
),
),
...[
Button(
padding: const EdgeInsets.symmetric(horizontal: 15.0, vertical: 10.0),
icon: const Icon(Icons.delete_sweep),
child: const Text('Clear image cache'),
onPressed: () async {
HapticFeedback.mediumImpact();
await DefaultCacheManager().emptyCache();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: AlertMessage(message: 'Image cache cleared'),
behavior: SnackBarBehavior.floating,
),
);
},
),
Button(
padding: const EdgeInsets.symmetric(horizontal: 15.0, vertical: 10.0),
onPressed: () => _showConfirmDialog(context),
icon: const Icon(Icons.restore),
child: const Text('Reset all settings'),
)
].map(
(button) => Container(
padding: const EdgeInsets.symmetric(
horizontal: 15.0,
vertical: 5.0,
),
width: double.infinity,
child: button,
),
),
],
);
}

View file

@ -1,15 +1,18 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:frosty/screens/settings/account_settings.dart';
import 'package:frosty/screens/settings/account/widgets/profile_card.dart';
import 'package:frosty/screens/settings/chat_settings.dart';
import 'package:frosty/screens/settings/general_settings.dart';
import 'package:frosty/screens/settings/other_settings.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/screens/settings/video_settings.dart';
import 'package:frosty/screens/settings/widgets/settings_tile_route.dart';
import 'package:frosty/widgets/app_bar.dart';
import 'package:frosty/widgets/section_header.dart';
import 'package:provider/provider.dart';
import 'package:simple_icons/simple_icons.dart';
import 'package:url_launcher/url_launcher.dart';
class Settings extends StatelessWidget {
@ -20,21 +23,21 @@ class Settings extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
appBar: FrostyAppBar(
title: const Text('Settings'),
actions: [
if (Platform.isAndroid)
IconButton(
tooltip: 'Support the app',
tooltip: 'Support Frosty',
onPressed: () => launchUrl(Uri.parse('https://www.buymeacoffee.com/tommychow'),
mode: settingsStore.launchUrlExternal ? LaunchMode.externalApplication : LaunchMode.inAppWebView),
icon: const FaIcon(FontAwesomeIcons.circleDollarToSlot),
icon: const Icon(SimpleIcons.buymeacoffee),
),
IconButton(
tooltip: 'View source on GitHub',
onPressed: () => launchUrl(Uri.parse('https://github.com/tommyxchow/frosty'),
mode: settingsStore.launchUrlExternal ? LaunchMode.externalApplication : LaunchMode.inAppWebView),
icon: const FaIcon(FontAwesomeIcons.github),
icon: const Icon(SimpleIcons.github),
),
],
),
@ -42,15 +45,26 @@ class Settings extends StatelessWidget {
bottom: false,
child: ListView(
children: [
const SizedBox(height: 10.0),
AccountSettings(
settingsStore: settingsStore,
authStore: context.read<AuthStore>(),
const SectionHeader('Account'),
ProfileCard(authStore: context.read<AuthStore>()),
const SectionHeader('Customize'),
SettingsTileRoute(
leading: const Icon(Icons.settings_outlined),
title: 'General',
child: GeneralSettings(settingsStore: settingsStore),
),
GeneralSettings(settingsStore: settingsStore),
VideoSettings(settingsStore: settingsStore),
ChatSettings(settingsStore: settingsStore),
OtherSettings(settingsStore: settingsStore),
SettingsTileRoute(
leading: const Icon(Icons.tv_rounded),
title: 'Video',
child: VideoSettings(settingsStore: settingsStore),
),
SettingsTileRoute(
leading: const Icon(Icons.chat_outlined),
title: 'Chat',
child: ChatSettings(settingsStore: settingsStore),
),
const SectionHeader('Other'),
OtherSettings(settingsStore: settingsStore)
],
),
),

View file

@ -1,11 +1,13 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/main.dart';
import 'package:frosty/screens/settings/stores/user_store.dart';
import 'package:frosty/widgets/button.dart';
import 'package:frosty/widgets/dialog.dart';
import 'package:mobx/mobx.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:webview_flutter/webview_flutter.dart';
@ -88,6 +90,52 @@ abstract class AuthBase with Store {
return NavigationDecision.navigate;
}
/// Shows a dialog verifying that the user is sure they want to block/unblock the target user.
Future<void> showBlockDialog(
BuildContext context, {
required String targetUser,
required String targetUserId,
}) {
final isBlocked = user.blockedUsers.where((blockedUser) => blockedUser.userId == targetUserId).isNotEmpty;
final title = isBlocked ? 'Unblock $targetUser' : 'Block $targetUser';
final message =
'Are you sure you want to ${isBlocked ? 'unblock $targetUser?' : 'block $targetUser? This will remove them from channel lists, search results, and chat messages.'}';
void onPressed() {
if (isBlocked) {
user.unblock(targetId: targetUserId, headers: headersTwitch);
} else {
user.block(
targetId: targetUserId,
displayName: targetUser,
headers: headersTwitch,
);
}
Navigator.pop(context);
}
return showDialog(
context: context,
builder: (context) => FrostyDialog(
title: title,
message: message,
actions: [
Button(
onPressed: onPressed,
child: const Text('Yes'),
),
Button(
onPressed: Navigator.of(context).pop,
color: Colors.grey,
child: const Text('Cancel'),
),
],
),
);
}
AuthBase({required this.twitchApi}) : user = UserStore(twitchApi: twitchApi);
/// Initialize by retrieving a token if it does not already exist.

View file

@ -13,16 +13,22 @@ class SettingsStore extends _SettingsStoreBase with _$SettingsStore {
abstract class _SettingsStoreBase with Store {
// * General Settings
// Theme defaults
static const defaultThemeType = ThemeType.system;
// Stream card defaults
static const defaultShowThumbnails = true;
static const defaultLargeStreamCard = false;
static const defaultShowThumbnailUptime = false;
// Links defaults
static const defaultLaunchUrlExternal = false;
// Theme options
@JsonKey(defaultValue: defaultThemeType, unknownEnumValue: ThemeType.system)
@observable
var themeType = defaultThemeType;
// Stream card options
@JsonKey(defaultValue: defaultShowThumbnails)
@observable
var showThumbnails = defaultShowThumbnails;
@ -31,10 +37,7 @@ abstract class _SettingsStoreBase with Store {
@observable
var largeStreamCard = defaultLargeStreamCard;
@JsonKey(defaultValue: defaultShowThumbnailUptime)
@observable
var showThumbnailUptime = defaultShowThumbnailUptime;
// Links options
@JsonKey(defaultValue: defaultLaunchUrlExternal)
@observable
var launchUrlExternal = defaultLaunchUrlExternal;
@ -42,22 +45,28 @@ abstract class _SettingsStoreBase with Store {
@action
void resetGeneralSettings() {
themeType = defaultThemeType;
showThumbnails = defaultShowThumbnails;
largeStreamCard = defaultLargeStreamCard;
showThumbnailUptime = defaultShowThumbnailUptime;
showThumbnails = defaultShowThumbnails;
launchUrlExternal = defaultLaunchUrlExternal;
}
// * Video Settings
// Player defaults
static const defaultShowVideo = true;
// Overlay defaults
static const defaultShowOverlay = true;
static const defaultToggleableOverlay = false;
static const defaultOverlayOpacity = 0.8;
static const defaultOverlayOpacity = 0.5;
// Player options
@JsonKey(defaultValue: defaultShowVideo)
@observable
var showVideo = defaultShowVideo;
// Overlay options
@JsonKey(defaultValue: defaultShowOverlay)
@observable
var showOverlay = defaultShowOverlay;
@ -73,107 +82,52 @@ abstract class _SettingsStoreBase with Store {
@action
void resetVideoSettings() {
showVideo = defaultShowVideo;
showOverlay = defaultShowOverlay;
toggleableOverlay = defaultToggleableOverlay;
overlayOpacity = defaultOverlayOpacity;
}
// * Chat Settings
static const defaultChatDelay = 0.0;
static const defaultChatOnlyPreventSleep = true;
static const defaultAutocomplete = true;
static const defaultLandscapeCutout = LandscapeCutoutType.none;
static const defaultShowBottomBar = true;
static const defaultEmoteMenuButtonOnLeft = false;
static const defaultLandscapeChatLeftSide = false;
static const defaultLandscapeForceVerticalChat = false;
static const defaultChatNotificationsOnBottom = false;
static const defaultChatWidth = 0.3;
static const defaultFullScreenChatOverlayOpacity = 0.5;
static const defaultUseReadableColors = true;
static const defaultShowDeletedMessages = false;
static const defaultShowChatMessageDividers = false;
static const defaultTimestampType = TimestampType.disabled;
static const defaultHighlightFirstTimeChatter = true;
static const defaultShowUserNotices = true;
// Message sizing defaults
static const defaultBadgeScale = 1.0;
static const defaultEmoteScale = 1.0;
static const defaultMessageScale = 1.0;
static const defaultMessageSpacing = 10.0;
static const defaultFontSize = 12.0;
@JsonKey(defaultValue: defaultChatDelay)
@observable
var chatDelay = defaultChatDelay;
// Message appearance defaults
static const defaultUseReadableColors = true;
static const defaultShowDeletedMessages = false;
static const defaultShowChatMessageDividers = false;
static const defaultTimestampType = TimestampType.disabled;
@JsonKey(defaultValue: defaultChatOnlyPreventSleep)
@observable
var chatOnlyPreventSleep = defaultChatOnlyPreventSleep;
// Delay defaults
static const defaultChatDelay = 0.0;
@JsonKey(defaultValue: defaultAutocomplete)
@observable
var autocomplete = defaultAutocomplete;
// Alert defaults
static const defaultHighlightFirstTimeChatter = true;
static const defaultShowUserNotices = true;
@JsonKey(defaultValue: defaultShowBottomBar)
@observable
var showBottomBar = defaultShowBottomBar;
// Layout defaults
static const defaultShowBottomBar = true;
static const defaultEmoteMenuButtonOnLeft = false;
static const defaultChatNotificationsOnBottom = false;
@JsonKey(defaultValue: defaultEmoteMenuButtonOnLeft)
@observable
var emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft;
// Landscape mode defaults
static const defaultLandscapeChatLeftSide = false;
static const defaultLandscapeForceVerticalChat = false;
static const defaultLandscapeCutout = LandscapeCutoutType.none;
static const defaultChatWidth = 0.3;
static const defaultFullScreenChatOverlayOpacity = 0.5;
@JsonKey(defaultValue: defaultLandscapeChatLeftSide)
@observable
var landscapeChatLeftSide = defaultLandscapeChatLeftSide;
// Sleep defaults
static const defaultChatOnlyPreventSleep = false;
@JsonKey(defaultValue: defaultLandscapeForceVerticalChat)
@observable
var landscapeForceVerticalChat = defaultLandscapeForceVerticalChat;
@JsonKey(defaultValue: defaultChatNotificationsOnBottom)
@observable
var chatNotificationsOnBottom = defaultChatNotificationsOnBottom;
@JsonKey(defaultValue: defaultLandscapeCutout)
@observable
var landscapeCutout = defaultLandscapeCutout;
@JsonKey(defaultValue: defaultChatWidth)
@observable
var chatWidth = defaultChatWidth;
@JsonKey(defaultValue: defaultFullScreenChatOverlayOpacity)
@observable
var fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity;
@JsonKey(defaultValue: defaultUseReadableColors)
@observable
var useReadableColors = defaultUseReadableColors;
@JsonKey(defaultValue: defaultShowDeletedMessages)
@observable
var showDeletedMessages = defaultShowDeletedMessages;
@JsonKey(defaultValue: defaultShowChatMessageDividers)
@observable
var showChatMessageDividers = defaultShowChatMessageDividers;
@JsonKey(defaultValue: defaultTimestampType, unknownEnumValue: TimestampType.disabled)
@observable
var timestampType = defaultTimestampType;
@JsonKey(defaultValue: defaultHighlightFirstTimeChatter)
@observable
var highlightFirstTimeChatter = defaultHighlightFirstTimeChatter;
@JsonKey(defaultValue: defaultShowUserNotices)
@observable
var showUserNotices = defaultHighlightFirstTimeChatter;
// Autocomplete defaults
static const defaultAutocomplete = true;
// Message sizing options
@JsonKey(defaultValue: defaultBadgeScale)
@observable
var badgeScale = defaultBadgeScale;
@ -194,30 +148,111 @@ abstract class _SettingsStoreBase with Store {
@observable
var fontSize = defaultFontSize;
// Message appearance options
@JsonKey(defaultValue: defaultUseReadableColors)
@observable
var useReadableColors = defaultUseReadableColors;
@JsonKey(defaultValue: defaultShowDeletedMessages)
@observable
var showDeletedMessages = defaultShowDeletedMessages;
@JsonKey(defaultValue: defaultShowChatMessageDividers)
@observable
var showChatMessageDividers = defaultShowChatMessageDividers;
@JsonKey(defaultValue: defaultTimestampType, unknownEnumValue: TimestampType.disabled)
@observable
var timestampType = defaultTimestampType;
// Delay options
@JsonKey(defaultValue: defaultChatDelay)
@observable
var chatDelay = defaultChatDelay;
// Alert options
@JsonKey(defaultValue: defaultHighlightFirstTimeChatter)
@observable
var highlightFirstTimeChatter = defaultHighlightFirstTimeChatter;
@JsonKey(defaultValue: defaultShowUserNotices)
@observable
var showUserNotices = defaultHighlightFirstTimeChatter;
// Layout options
@JsonKey(defaultValue: defaultShowBottomBar)
@observable
var showBottomBar = defaultShowBottomBar;
@JsonKey(defaultValue: defaultEmoteMenuButtonOnLeft)
@observable
var emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft;
@JsonKey(defaultValue: defaultChatNotificationsOnBottom)
@observable
var chatNotificationsOnBottom = defaultChatNotificationsOnBottom;
// Landscape mode options
@JsonKey(defaultValue: defaultLandscapeChatLeftSide)
@observable
var landscapeChatLeftSide = defaultLandscapeChatLeftSide;
@JsonKey(defaultValue: defaultLandscapeForceVerticalChat)
@observable
var landscapeForceVerticalChat = defaultLandscapeForceVerticalChat;
@JsonKey(defaultValue: defaultLandscapeCutout)
@observable
var landscapeCutout = defaultLandscapeCutout;
@JsonKey(defaultValue: defaultChatWidth)
@observable
var chatWidth = defaultChatWidth;
@JsonKey(defaultValue: defaultFullScreenChatOverlayOpacity)
@observable
var fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity;
// Sleep options
@JsonKey(defaultValue: defaultChatOnlyPreventSleep)
@observable
var chatOnlyPreventSleep = defaultChatOnlyPreventSleep;
// Autocomplete options
@JsonKey(defaultValue: defaultAutocomplete)
@observable
var autocomplete = defaultAutocomplete;
@action
void resetChatSettings() {
chatDelay = defaultChatDelay;
chatOnlyPreventSleep = defaultChatOnlyPreventSleep;
autocomplete = defaultAutocomplete;
showBottomBar = defaultShowBottomBar;
emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft;
landscapeChatLeftSide = defaultLandscapeChatLeftSide;
landscapeForceVerticalChat = defaultLandscapeForceVerticalChat;
chatNotificationsOnBottom = defaultChatNotificationsOnBottom;
landscapeCutout = defaultLandscapeCutout;
chatWidth = defaultChatWidth;
fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity;
useReadableColors = defaultUseReadableColors;
showDeletedMessages = defaultShowDeletedMessages;
showChatMessageDividers = defaultShowChatMessageDividers;
timestampType = defaultTimestampType;
highlightFirstTimeChatter = defaultHighlightFirstTimeChatter;
showUserNotices = defaultShowUserNotices;
badgeScale = defaultBadgeScale;
emoteScale = defaultEmoteScale;
messageScale = defaultMessageScale;
messageSpacing = defaultMessageSpacing;
fontSize = defaultFontSize;
useReadableColors = defaultUseReadableColors;
showDeletedMessages = defaultShowDeletedMessages;
showChatMessageDividers = defaultShowChatMessageDividers;
timestampType = defaultTimestampType;
chatDelay = defaultChatDelay;
highlightFirstTimeChatter = defaultHighlightFirstTimeChatter;
showUserNotices = defaultShowUserNotices;
showBottomBar = defaultShowBottomBar;
emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft;
chatNotificationsOnBottom = defaultChatNotificationsOnBottom;
landscapeChatLeftSide = defaultLandscapeChatLeftSide;
landscapeForceVerticalChat = defaultLandscapeForceVerticalChat;
landscapeCutout = defaultLandscapeCutout;
chatWidth = defaultChatWidth;
fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity;
chatOnlyPreventSleep = defaultChatOnlyPreventSleep;
autocomplete = defaultAutocomplete;
}
// * Other settings
@ -234,17 +269,12 @@ abstract class _SettingsStoreBase with Store {
// * Global configs
static const defaultFullScreen = false;
static const defaultExpandInfo = true;
static const defaultFullScreenChatOverlay = false;
@JsonKey(defaultValue: defaultFullScreen)
@observable
var fullScreen = defaultFullScreen;
@JsonKey(defaultValue: defaultExpandInfo)
@observable
var expandInfo = defaultExpandInfo;
@JsonKey(defaultValue: defaultFullScreenChatOverlay)
@observable
var fullScreenChatOverlay = defaultFullScreenChatOverlay;
@ -252,7 +282,6 @@ abstract class _SettingsStoreBase with Store {
@action
void resetGlobalConfigs() {
fullScreen = defaultFullScreen;
expandInfo = defaultExpandInfo;
fullScreenChatOverlay = defaultFullScreenChatOverlay;
}

View file

@ -13,28 +13,16 @@ SettingsStore _$SettingsStoreFromJson(Map<String, dynamic> json) =>
ThemeType.system
..showThumbnails = json['showThumbnails'] as bool? ?? true
..largeStreamCard = json['largeStreamCard'] as bool? ?? false
..showThumbnailUptime = json['showThumbnailUptime'] as bool? ?? false
..launchUrlExternal = json['launchUrlExternal'] as bool? ?? false
..showVideo = json['showVideo'] as bool? ?? true
..showOverlay = json['showOverlay'] as bool? ?? true
..toggleableOverlay = json['toggleableOverlay'] as bool? ?? false
..overlayOpacity = (json['overlayOpacity'] as num?)?.toDouble() ?? 0.8
..chatDelay = (json['chatDelay'] as num?)?.toDouble() ?? 0.0
..chatOnlyPreventSleep = json['chatOnlyPreventSleep'] as bool? ?? true
..autocomplete = json['autocomplete'] as bool? ?? true
..showBottomBar = json['showBottomBar'] as bool? ?? true
..emoteMenuButtonOnLeft = json['emoteMenuButtonOnLeft'] as bool? ?? false
..landscapeChatLeftSide = json['landscapeChatLeftSide'] as bool? ?? false
..landscapeForceVerticalChat =
json['landscapeForceVerticalChat'] as bool? ?? false
..chatNotificationsOnBottom =
json['chatNotificationsOnBottom'] as bool? ?? false
..landscapeCutout = $enumDecodeNullable(
_$LandscapeCutoutTypeEnumMap, json['landscapeCutout']) ??
LandscapeCutoutType.none
..chatWidth = (json['chatWidth'] as num?)?.toDouble() ?? 0.3
..fullScreenChatOverlayOpacity =
(json['fullScreenChatOverlayOpacity'] as num?)?.toDouble() ?? 0.5
..overlayOpacity = (json['overlayOpacity'] as num?)?.toDouble() ?? 0.5
..badgeScale = (json['badgeScale'] as num?)?.toDouble() ?? 1.0
..emoteScale = (json['emoteScale'] as num?)?.toDouble() ?? 1.0
..messageScale = (json['messageScale'] as num?)?.toDouble() ?? 1.0
..messageSpacing = (json['messageSpacing'] as num?)?.toDouble() ?? 10.0
..fontSize = (json['fontSize'] as num?)?.toDouble() ?? 12.0
..useReadableColors = json['useReadableColors'] as bool? ?? true
..showDeletedMessages = json['showDeletedMessages'] as bool? ?? false
..showChatMessageDividers =
@ -43,17 +31,27 @@ SettingsStore _$SettingsStoreFromJson(Map<String, dynamic> json) =>
_$TimestampTypeEnumMap, json['timestampType'],
unknownValue: TimestampType.disabled) ??
TimestampType.disabled
..chatDelay = (json['chatDelay'] as num?)?.toDouble() ?? 0.0
..highlightFirstTimeChatter =
json['highlightFirstTimeChatter'] as bool? ?? true
..showUserNotices = json['showUserNotices'] as bool? ?? true
..badgeScale = (json['badgeScale'] as num?)?.toDouble() ?? 1.0
..emoteScale = (json['emoteScale'] as num?)?.toDouble() ?? 1.0
..messageScale = (json['messageScale'] as num?)?.toDouble() ?? 1.0
..messageSpacing = (json['messageSpacing'] as num?)?.toDouble() ?? 10.0
..fontSize = (json['fontSize'] as num?)?.toDouble() ?? 12.0
..showBottomBar = json['showBottomBar'] as bool? ?? true
..emoteMenuButtonOnLeft = json['emoteMenuButtonOnLeft'] as bool? ?? false
..chatNotificationsOnBottom =
json['chatNotificationsOnBottom'] as bool? ?? false
..landscapeChatLeftSide = json['landscapeChatLeftSide'] as bool? ?? false
..landscapeForceVerticalChat =
json['landscapeForceVerticalChat'] as bool? ?? false
..landscapeCutout = $enumDecodeNullable(
_$LandscapeCutoutTypeEnumMap, json['landscapeCutout']) ??
LandscapeCutoutType.none
..chatWidth = (json['chatWidth'] as num?)?.toDouble() ?? 0.3
..fullScreenChatOverlayOpacity =
(json['fullScreenChatOverlayOpacity'] as num?)?.toDouble() ?? 0.5
..chatOnlyPreventSleep = json['chatOnlyPreventSleep'] as bool? ?? false
..autocomplete = json['autocomplete'] as bool? ?? true
..sendCrashLogs = json['sendCrashLogs'] as bool? ?? true
..fullScreen = json['fullScreen'] as bool? ?? false
..expandInfo = json['expandInfo'] as bool? ?? true
..fullScreenChatOverlay = json['fullScreenChatOverlay'] as bool? ?? false;
Map<String, dynamic> _$SettingsStoreToJson(SettingsStore instance) =>
@ -61,38 +59,36 @@ Map<String, dynamic> _$SettingsStoreToJson(SettingsStore instance) =>
'themeType': _$ThemeTypeEnumMap[instance.themeType]!,
'showThumbnails': instance.showThumbnails,
'largeStreamCard': instance.largeStreamCard,
'showThumbnailUptime': instance.showThumbnailUptime,
'launchUrlExternal': instance.launchUrlExternal,
'showVideo': instance.showVideo,
'showOverlay': instance.showOverlay,
'toggleableOverlay': instance.toggleableOverlay,
'overlayOpacity': instance.overlayOpacity,
'chatDelay': instance.chatDelay,
'chatOnlyPreventSleep': instance.chatOnlyPreventSleep,
'autocomplete': instance.autocomplete,
'showBottomBar': instance.showBottomBar,
'emoteMenuButtonOnLeft': instance.emoteMenuButtonOnLeft,
'landscapeChatLeftSide': instance.landscapeChatLeftSide,
'landscapeForceVerticalChat': instance.landscapeForceVerticalChat,
'chatNotificationsOnBottom': instance.chatNotificationsOnBottom,
'landscapeCutout':
_$LandscapeCutoutTypeEnumMap[instance.landscapeCutout]!,
'chatWidth': instance.chatWidth,
'fullScreenChatOverlayOpacity': instance.fullScreenChatOverlayOpacity,
'useReadableColors': instance.useReadableColors,
'showDeletedMessages': instance.showDeletedMessages,
'showChatMessageDividers': instance.showChatMessageDividers,
'timestampType': _$TimestampTypeEnumMap[instance.timestampType]!,
'highlightFirstTimeChatter': instance.highlightFirstTimeChatter,
'showUserNotices': instance.showUserNotices,
'badgeScale': instance.badgeScale,
'emoteScale': instance.emoteScale,
'messageScale': instance.messageScale,
'messageSpacing': instance.messageSpacing,
'fontSize': instance.fontSize,
'useReadableColors': instance.useReadableColors,
'showDeletedMessages': instance.showDeletedMessages,
'showChatMessageDividers': instance.showChatMessageDividers,
'timestampType': _$TimestampTypeEnumMap[instance.timestampType]!,
'chatDelay': instance.chatDelay,
'highlightFirstTimeChatter': instance.highlightFirstTimeChatter,
'showUserNotices': instance.showUserNotices,
'showBottomBar': instance.showBottomBar,
'emoteMenuButtonOnLeft': instance.emoteMenuButtonOnLeft,
'chatNotificationsOnBottom': instance.chatNotificationsOnBottom,
'landscapeChatLeftSide': instance.landscapeChatLeftSide,
'landscapeForceVerticalChat': instance.landscapeForceVerticalChat,
'landscapeCutout':
_$LandscapeCutoutTypeEnumMap[instance.landscapeCutout]!,
'chatWidth': instance.chatWidth,
'fullScreenChatOverlayOpacity': instance.fullScreenChatOverlayOpacity,
'chatOnlyPreventSleep': instance.chatOnlyPreventSleep,
'autocomplete': instance.autocomplete,
'sendCrashLogs': instance.sendCrashLogs,
'fullScreen': instance.fullScreen,
'expandInfo': instance.expandInfo,
'fullScreenChatOverlay': instance.fullScreenChatOverlay,
};
@ -103,6 +99,12 @@ const _$ThemeTypeEnumMap = {
ThemeType.black: 'black',
};
const _$TimestampTypeEnumMap = {
TimestampType.disabled: 'disabled',
TimestampType.twelve: 'twelve',
TimestampType.twentyFour: 'twentyFour',
};
const _$LandscapeCutoutTypeEnumMap = {
LandscapeCutoutType.none: 'none',
LandscapeCutoutType.left: 'left',
@ -110,12 +112,6 @@ const _$LandscapeCutoutTypeEnumMap = {
LandscapeCutoutType.both: 'both',
};
const _$TimestampTypeEnumMap = {
TimestampType.disabled: 'disabled',
TimestampType.twelve: 'twelve',
TimestampType.twentyFour: 'twentyFour',
};
// **************************************************************************
// StoreGenerator
// **************************************************************************
@ -171,22 +167,6 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
});
}
late final _$showThumbnailUptimeAtom =
Atom(name: '_SettingsStoreBase.showThumbnailUptime', context: context);
@override
bool get showThumbnailUptime {
_$showThumbnailUptimeAtom.reportRead();
return super.showThumbnailUptime;
}
@override
set showThumbnailUptime(bool value) {
_$showThumbnailUptimeAtom.reportWrite(value, super.showThumbnailUptime, () {
super.showThumbnailUptime = value;
});
}
late final _$launchUrlExternalAtom =
Atom(name: '_SettingsStoreBase.launchUrlExternal', context: context);
@ -267,287 +247,6 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
});
}
late final _$chatDelayAtom =
Atom(name: '_SettingsStoreBase.chatDelay', context: context);
@override
double get chatDelay {
_$chatDelayAtom.reportRead();
return super.chatDelay;
}
@override
set chatDelay(double value) {
_$chatDelayAtom.reportWrite(value, super.chatDelay, () {
super.chatDelay = value;
});
}
late final _$chatOnlyPreventSleepAtom =
Atom(name: '_SettingsStoreBase.chatOnlyPreventSleep', context: context);
@override
bool get chatOnlyPreventSleep {
_$chatOnlyPreventSleepAtom.reportRead();
return super.chatOnlyPreventSleep;
}
@override
set chatOnlyPreventSleep(bool value) {
_$chatOnlyPreventSleepAtom.reportWrite(value, super.chatOnlyPreventSleep,
() {
super.chatOnlyPreventSleep = value;
});
}
late final _$autocompleteAtom =
Atom(name: '_SettingsStoreBase.autocomplete', context: context);
@override
bool get autocomplete {
_$autocompleteAtom.reportRead();
return super.autocomplete;
}
@override
set autocomplete(bool value) {
_$autocompleteAtom.reportWrite(value, super.autocomplete, () {
super.autocomplete = value;
});
}
late final _$showBottomBarAtom =
Atom(name: '_SettingsStoreBase.showBottomBar', context: context);
@override
bool get showBottomBar {
_$showBottomBarAtom.reportRead();
return super.showBottomBar;
}
@override
set showBottomBar(bool value) {
_$showBottomBarAtom.reportWrite(value, super.showBottomBar, () {
super.showBottomBar = value;
});
}
late final _$emoteMenuButtonOnLeftAtom =
Atom(name: '_SettingsStoreBase.emoteMenuButtonOnLeft', context: context);
@override
bool get emoteMenuButtonOnLeft {
_$emoteMenuButtonOnLeftAtom.reportRead();
return super.emoteMenuButtonOnLeft;
}
@override
set emoteMenuButtonOnLeft(bool value) {
_$emoteMenuButtonOnLeftAtom.reportWrite(value, super.emoteMenuButtonOnLeft,
() {
super.emoteMenuButtonOnLeft = value;
});
}
late final _$landscapeChatLeftSideAtom =
Atom(name: '_SettingsStoreBase.landscapeChatLeftSide', context: context);
@override
bool get landscapeChatLeftSide {
_$landscapeChatLeftSideAtom.reportRead();
return super.landscapeChatLeftSide;
}
@override
set landscapeChatLeftSide(bool value) {
_$landscapeChatLeftSideAtom.reportWrite(value, super.landscapeChatLeftSide,
() {
super.landscapeChatLeftSide = value;
});
}
late final _$landscapeForceVerticalChatAtom = Atom(
name: '_SettingsStoreBase.landscapeForceVerticalChat', context: context);
@override
bool get landscapeForceVerticalChat {
_$landscapeForceVerticalChatAtom.reportRead();
return super.landscapeForceVerticalChat;
}
@override
set landscapeForceVerticalChat(bool value) {
_$landscapeForceVerticalChatAtom
.reportWrite(value, super.landscapeForceVerticalChat, () {
super.landscapeForceVerticalChat = value;
});
}
late final _$chatNotificationsOnBottomAtom = Atom(
name: '_SettingsStoreBase.chatNotificationsOnBottom', context: context);
@override
bool get chatNotificationsOnBottom {
_$chatNotificationsOnBottomAtom.reportRead();
return super.chatNotificationsOnBottom;
}
@override
set chatNotificationsOnBottom(bool value) {
_$chatNotificationsOnBottomAtom
.reportWrite(value, super.chatNotificationsOnBottom, () {
super.chatNotificationsOnBottom = value;
});
}
late final _$landscapeCutoutAtom =
Atom(name: '_SettingsStoreBase.landscapeCutout', context: context);
@override
LandscapeCutoutType get landscapeCutout {
_$landscapeCutoutAtom.reportRead();
return super.landscapeCutout;
}
@override
set landscapeCutout(LandscapeCutoutType value) {
_$landscapeCutoutAtom.reportWrite(value, super.landscapeCutout, () {
super.landscapeCutout = value;
});
}
late final _$chatWidthAtom =
Atom(name: '_SettingsStoreBase.chatWidth', context: context);
@override
double get chatWidth {
_$chatWidthAtom.reportRead();
return super.chatWidth;
}
@override
set chatWidth(double value) {
_$chatWidthAtom.reportWrite(value, super.chatWidth, () {
super.chatWidth = value;
});
}
late final _$fullScreenChatOverlayOpacityAtom = Atom(
name: '_SettingsStoreBase.fullScreenChatOverlayOpacity',
context: context);
@override
double get fullScreenChatOverlayOpacity {
_$fullScreenChatOverlayOpacityAtom.reportRead();
return super.fullScreenChatOverlayOpacity;
}
@override
set fullScreenChatOverlayOpacity(double value) {
_$fullScreenChatOverlayOpacityAtom
.reportWrite(value, super.fullScreenChatOverlayOpacity, () {
super.fullScreenChatOverlayOpacity = value;
});
}
late final _$useReadableColorsAtom =
Atom(name: '_SettingsStoreBase.useReadableColors', context: context);
@override
bool get useReadableColors {
_$useReadableColorsAtom.reportRead();
return super.useReadableColors;
}
@override
set useReadableColors(bool value) {
_$useReadableColorsAtom.reportWrite(value, super.useReadableColors, () {
super.useReadableColors = value;
});
}
late final _$showDeletedMessagesAtom =
Atom(name: '_SettingsStoreBase.showDeletedMessages', context: context);
@override
bool get showDeletedMessages {
_$showDeletedMessagesAtom.reportRead();
return super.showDeletedMessages;
}
@override
set showDeletedMessages(bool value) {
_$showDeletedMessagesAtom.reportWrite(value, super.showDeletedMessages, () {
super.showDeletedMessages = value;
});
}
late final _$showChatMessageDividersAtom = Atom(
name: '_SettingsStoreBase.showChatMessageDividers', context: context);
@override
bool get showChatMessageDividers {
_$showChatMessageDividersAtom.reportRead();
return super.showChatMessageDividers;
}
@override
set showChatMessageDividers(bool value) {
_$showChatMessageDividersAtom
.reportWrite(value, super.showChatMessageDividers, () {
super.showChatMessageDividers = value;
});
}
late final _$timestampTypeAtom =
Atom(name: '_SettingsStoreBase.timestampType', context: context);
@override
TimestampType get timestampType {
_$timestampTypeAtom.reportRead();
return super.timestampType;
}
@override
set timestampType(TimestampType value) {
_$timestampTypeAtom.reportWrite(value, super.timestampType, () {
super.timestampType = value;
});
}
late final _$highlightFirstTimeChatterAtom = Atom(
name: '_SettingsStoreBase.highlightFirstTimeChatter', context: context);
@override
bool get highlightFirstTimeChatter {
_$highlightFirstTimeChatterAtom.reportRead();
return super.highlightFirstTimeChatter;
}
@override
set highlightFirstTimeChatter(bool value) {
_$highlightFirstTimeChatterAtom
.reportWrite(value, super.highlightFirstTimeChatter, () {
super.highlightFirstTimeChatter = value;
});
}
late final _$showUserNoticesAtom =
Atom(name: '_SettingsStoreBase.showUserNotices', context: context);
@override
bool get showUserNotices {
_$showUserNoticesAtom.reportRead();
return super.showUserNotices;
}
@override
set showUserNotices(bool value) {
_$showUserNoticesAtom.reportWrite(value, super.showUserNotices, () {
super.showUserNotices = value;
});
}
late final _$badgeScaleAtom =
Atom(name: '_SettingsStoreBase.badgeScale', context: context);
@ -628,6 +327,287 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
});
}
late final _$useReadableColorsAtom =
Atom(name: '_SettingsStoreBase.useReadableColors', context: context);
@override
bool get useReadableColors {
_$useReadableColorsAtom.reportRead();
return super.useReadableColors;
}
@override
set useReadableColors(bool value) {
_$useReadableColorsAtom.reportWrite(value, super.useReadableColors, () {
super.useReadableColors = value;
});
}
late final _$showDeletedMessagesAtom =
Atom(name: '_SettingsStoreBase.showDeletedMessages', context: context);
@override
bool get showDeletedMessages {
_$showDeletedMessagesAtom.reportRead();
return super.showDeletedMessages;
}
@override
set showDeletedMessages(bool value) {
_$showDeletedMessagesAtom.reportWrite(value, super.showDeletedMessages, () {
super.showDeletedMessages = value;
});
}
late final _$showChatMessageDividersAtom = Atom(
name: '_SettingsStoreBase.showChatMessageDividers', context: context);
@override
bool get showChatMessageDividers {
_$showChatMessageDividersAtom.reportRead();
return super.showChatMessageDividers;
}
@override
set showChatMessageDividers(bool value) {
_$showChatMessageDividersAtom
.reportWrite(value, super.showChatMessageDividers, () {
super.showChatMessageDividers = value;
});
}
late final _$timestampTypeAtom =
Atom(name: '_SettingsStoreBase.timestampType', context: context);
@override
TimestampType get timestampType {
_$timestampTypeAtom.reportRead();
return super.timestampType;
}
@override
set timestampType(TimestampType value) {
_$timestampTypeAtom.reportWrite(value, super.timestampType, () {
super.timestampType = value;
});
}
late final _$chatDelayAtom =
Atom(name: '_SettingsStoreBase.chatDelay', context: context);
@override
double get chatDelay {
_$chatDelayAtom.reportRead();
return super.chatDelay;
}
@override
set chatDelay(double value) {
_$chatDelayAtom.reportWrite(value, super.chatDelay, () {
super.chatDelay = value;
});
}
late final _$highlightFirstTimeChatterAtom = Atom(
name: '_SettingsStoreBase.highlightFirstTimeChatter', context: context);
@override
bool get highlightFirstTimeChatter {
_$highlightFirstTimeChatterAtom.reportRead();
return super.highlightFirstTimeChatter;
}
@override
set highlightFirstTimeChatter(bool value) {
_$highlightFirstTimeChatterAtom
.reportWrite(value, super.highlightFirstTimeChatter, () {
super.highlightFirstTimeChatter = value;
});
}
late final _$showUserNoticesAtom =
Atom(name: '_SettingsStoreBase.showUserNotices', context: context);
@override
bool get showUserNotices {
_$showUserNoticesAtom.reportRead();
return super.showUserNotices;
}
@override
set showUserNotices(bool value) {
_$showUserNoticesAtom.reportWrite(value, super.showUserNotices, () {
super.showUserNotices = value;
});
}
late final _$showBottomBarAtom =
Atom(name: '_SettingsStoreBase.showBottomBar', context: context);
@override
bool get showBottomBar {
_$showBottomBarAtom.reportRead();
return super.showBottomBar;
}
@override
set showBottomBar(bool value) {
_$showBottomBarAtom.reportWrite(value, super.showBottomBar, () {
super.showBottomBar = value;
});
}
late final _$emoteMenuButtonOnLeftAtom =
Atom(name: '_SettingsStoreBase.emoteMenuButtonOnLeft', context: context);
@override
bool get emoteMenuButtonOnLeft {
_$emoteMenuButtonOnLeftAtom.reportRead();
return super.emoteMenuButtonOnLeft;
}
@override
set emoteMenuButtonOnLeft(bool value) {
_$emoteMenuButtonOnLeftAtom.reportWrite(value, super.emoteMenuButtonOnLeft,
() {
super.emoteMenuButtonOnLeft = value;
});
}
late final _$chatNotificationsOnBottomAtom = Atom(
name: '_SettingsStoreBase.chatNotificationsOnBottom', context: context);
@override
bool get chatNotificationsOnBottom {
_$chatNotificationsOnBottomAtom.reportRead();
return super.chatNotificationsOnBottom;
}
@override
set chatNotificationsOnBottom(bool value) {
_$chatNotificationsOnBottomAtom
.reportWrite(value, super.chatNotificationsOnBottom, () {
super.chatNotificationsOnBottom = value;
});
}
late final _$landscapeChatLeftSideAtom =
Atom(name: '_SettingsStoreBase.landscapeChatLeftSide', context: context);
@override
bool get landscapeChatLeftSide {
_$landscapeChatLeftSideAtom.reportRead();
return super.landscapeChatLeftSide;
}
@override
set landscapeChatLeftSide(bool value) {
_$landscapeChatLeftSideAtom.reportWrite(value, super.landscapeChatLeftSide,
() {
super.landscapeChatLeftSide = value;
});
}
late final _$landscapeForceVerticalChatAtom = Atom(
name: '_SettingsStoreBase.landscapeForceVerticalChat', context: context);
@override
bool get landscapeForceVerticalChat {
_$landscapeForceVerticalChatAtom.reportRead();
return super.landscapeForceVerticalChat;
}
@override
set landscapeForceVerticalChat(bool value) {
_$landscapeForceVerticalChatAtom
.reportWrite(value, super.landscapeForceVerticalChat, () {
super.landscapeForceVerticalChat = value;
});
}
late final _$landscapeCutoutAtom =
Atom(name: '_SettingsStoreBase.landscapeCutout', context: context);
@override
LandscapeCutoutType get landscapeCutout {
_$landscapeCutoutAtom.reportRead();
return super.landscapeCutout;
}
@override
set landscapeCutout(LandscapeCutoutType value) {
_$landscapeCutoutAtom.reportWrite(value, super.landscapeCutout, () {
super.landscapeCutout = value;
});
}
late final _$chatWidthAtom =
Atom(name: '_SettingsStoreBase.chatWidth', context: context);
@override
double get chatWidth {
_$chatWidthAtom.reportRead();
return super.chatWidth;
}
@override
set chatWidth(double value) {
_$chatWidthAtom.reportWrite(value, super.chatWidth, () {
super.chatWidth = value;
});
}
late final _$fullScreenChatOverlayOpacityAtom = Atom(
name: '_SettingsStoreBase.fullScreenChatOverlayOpacity',
context: context);
@override
double get fullScreenChatOverlayOpacity {
_$fullScreenChatOverlayOpacityAtom.reportRead();
return super.fullScreenChatOverlayOpacity;
}
@override
set fullScreenChatOverlayOpacity(double value) {
_$fullScreenChatOverlayOpacityAtom
.reportWrite(value, super.fullScreenChatOverlayOpacity, () {
super.fullScreenChatOverlayOpacity = value;
});
}
late final _$chatOnlyPreventSleepAtom =
Atom(name: '_SettingsStoreBase.chatOnlyPreventSleep', context: context);
@override
bool get chatOnlyPreventSleep {
_$chatOnlyPreventSleepAtom.reportRead();
return super.chatOnlyPreventSleep;
}
@override
set chatOnlyPreventSleep(bool value) {
_$chatOnlyPreventSleepAtom.reportWrite(value, super.chatOnlyPreventSleep,
() {
super.chatOnlyPreventSleep = value;
});
}
late final _$autocompleteAtom =
Atom(name: '_SettingsStoreBase.autocomplete', context: context);
@override
bool get autocomplete {
_$autocompleteAtom.reportRead();
return super.autocomplete;
}
@override
set autocomplete(bool value) {
_$autocompleteAtom.reportWrite(value, super.autocomplete, () {
super.autocomplete = value;
});
}
late final _$sendCrashLogsAtom =
Atom(name: '_SettingsStoreBase.sendCrashLogs', context: context);
@ -660,22 +640,6 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
});
}
late final _$expandInfoAtom =
Atom(name: '_SettingsStoreBase.expandInfo', context: context);
@override
bool get expandInfo {
_$expandInfoAtom.reportRead();
return super.expandInfo;
}
@override
set expandInfo(bool value) {
_$expandInfoAtom.reportWrite(value, super.expandInfo, () {
super.expandInfo = value;
});
}
late final _$fullScreenChatOverlayAtom =
Atom(name: '_SettingsStoreBase.fullScreenChatOverlay', context: context);
@ -768,37 +732,35 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
themeType: ${themeType},
showThumbnails: ${showThumbnails},
largeStreamCard: ${largeStreamCard},
showThumbnailUptime: ${showThumbnailUptime},
launchUrlExternal: ${launchUrlExternal},
showVideo: ${showVideo},
showOverlay: ${showOverlay},
toggleableOverlay: ${toggleableOverlay},
overlayOpacity: ${overlayOpacity},
chatDelay: ${chatDelay},
chatOnlyPreventSleep: ${chatOnlyPreventSleep},
autocomplete: ${autocomplete},
showBottomBar: ${showBottomBar},
emoteMenuButtonOnLeft: ${emoteMenuButtonOnLeft},
landscapeChatLeftSide: ${landscapeChatLeftSide},
landscapeForceVerticalChat: ${landscapeForceVerticalChat},
chatNotificationsOnBottom: ${chatNotificationsOnBottom},
landscapeCutout: ${landscapeCutout},
chatWidth: ${chatWidth},
fullScreenChatOverlayOpacity: ${fullScreenChatOverlayOpacity},
useReadableColors: ${useReadableColors},
showDeletedMessages: ${showDeletedMessages},
showChatMessageDividers: ${showChatMessageDividers},
timestampType: ${timestampType},
highlightFirstTimeChatter: ${highlightFirstTimeChatter},
showUserNotices: ${showUserNotices},
badgeScale: ${badgeScale},
emoteScale: ${emoteScale},
messageScale: ${messageScale},
messageSpacing: ${messageSpacing},
fontSize: ${fontSize},
useReadableColors: ${useReadableColors},
showDeletedMessages: ${showDeletedMessages},
showChatMessageDividers: ${showChatMessageDividers},
timestampType: ${timestampType},
chatDelay: ${chatDelay},
highlightFirstTimeChatter: ${highlightFirstTimeChatter},
showUserNotices: ${showUserNotices},
showBottomBar: ${showBottomBar},
emoteMenuButtonOnLeft: ${emoteMenuButtonOnLeft},
chatNotificationsOnBottom: ${chatNotificationsOnBottom},
landscapeChatLeftSide: ${landscapeChatLeftSide},
landscapeForceVerticalChat: ${landscapeForceVerticalChat},
landscapeCutout: ${landscapeCutout},
chatWidth: ${chatWidth},
fullScreenChatOverlayOpacity: ${fullScreenChatOverlayOpacity},
chatOnlyPreventSleep: ${chatOnlyPreventSleep},
autocomplete: ${autocomplete},
sendCrashLogs: ${sendCrashLogs},
fullScreen: ${fullScreen},
expandInfo: ${expandInfo},
fullScreenChatOverlay: ${fullScreenChatOverlay}
''';
}

View file

@ -1,6 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/screens/settings/widgets/settings_list_slider.dart';
import 'package:frosty/screens/settings/widgets/settings_list_switch.dart';
import 'package:frosty/widgets/section_header.dart';
class VideoSettings extends StatelessWidget {
final SettingsStore settingsStore;
@ -10,58 +13,34 @@ class VideoSettings extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Observer(
builder: (context) => ExpansionTile(
leading: const Icon(Icons.ondemand_video),
title: const Text(
'Video',
style: TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.bold,
),
),
builder: (context) => ListView(
children: [
SwitchListTile.adaptive(
title: const Text('Video'),
const SectionHeader('Player'),
SettingsListSwitch(
title: 'Enable video',
value: settingsStore.showVideo,
onChanged: (newValue) => settingsStore.showVideo = newValue,
),
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Custom overlay'),
subtitle: const Text('Replaces Twitch\'s default overlay with a mobile-friendly version.'),
const SectionHeader('Overlay'),
SettingsListSwitch(
title: 'Use custom video overlay',
subtitle: const Text('Replaces Twitch\'s default web overlay with a mobile-friendly version.'),
value: settingsStore.showOverlay,
onChanged: settingsStore.showVideo ? (newValue) => settingsStore.showOverlay = newValue : null,
onChanged: (newValue) => settingsStore.showOverlay = newValue,
),
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Long-press player to toggle overlay'),
subtitle: const Text('Allows switching between Twitch\'s default overlay and the custom overlay.'),
SettingsListSwitch(
title: 'Long-press player to toggle overlay',
subtitle: const Text('Allows switching between Twitch\'s overlay and the custom overlay.'),
value: settingsStore.toggleableOverlay,
onChanged: settingsStore.showVideo ? (newValue) => settingsStore.toggleableOverlay = newValue : null,
onChanged: (newValue) => settingsStore.toggleableOverlay = newValue,
),
const SizedBox(height: 15.0),
ListTile(
title: Row(
children: [
const Text('Custom overlay opacity'),
const Spacer(),
Text('${(settingsStore.overlayOpacity * 100).toStringAsFixed(0)}%'),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Slider.adaptive(
value: settingsStore.overlayOpacity,
min: 0.0,
max: 1.0,
divisions: 10,
onChanged: (newValue) => settingsStore.overlayOpacity = newValue,
),
const Text('Adjusts the opacity (transparency) of the stream overlay when active.'),
const SizedBox(height: 15),
],
),
SettingsListSlider(
title: 'Custom overlay opacity',
trailing: '${(settingsStore.overlayOpacity * 100).toStringAsFixed(0)}%',
subtitle: 'Adjusts the opacity (transparency) of the custom video overlay when active.',
value: settingsStore.overlayOpacity,
divisions: 10,
onChanged: (newValue) => settingsStore.overlayOpacity = newValue,
),
],
),

View file

@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:frosty/widgets/bottom_sheet.dart';
/// A custom-styled adaptive [ListTile] with options to select.
class SettingsListSelect extends StatelessWidget {
final String title;
final String? subtitle;
final String selectedOption;
final List<String> options;
final ValueChanged<String> onChanged;
const SettingsListSelect({
Key? key,
required this.title,
this.subtitle,
required this.selectedOption,
required this.options,
required this.onChanged,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return ListTile(
isThreeLine: subtitle != null,
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
subtitle: subtitle != null ? Text(subtitle!) : null,
trailing: SizedBox(
height: double.infinity,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Opacity(
opacity: 0.8,
child: Text(
selectedOption,
),
),
const Icon(Icons.chevron_right_rounded),
],
),
),
onTap: () => showModalBottomSheet(
backgroundColor: Colors.transparent,
isScrollControlled: true,
context: context,
builder: (context) => FrostyBottomSheet(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
'Select ${title.toLowerCase()}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
),
),
...options
.map((option) => ListTile(
title: Text(option),
trailing: selectedOption == option ? const Icon(Icons.check_rounded) : null,
onTap: () {
onChanged(option);
Navigator.of(context).pop();
}))
.toList()
],
),
),
),
);
}
}

View file

@ -0,0 +1,56 @@
import 'dart:ui';
import 'package:flutter/material.dart';
/// A custom-styled adaptive [Slider].
class SettingsListSlider extends StatelessWidget {
final String title;
final String trailing;
final String? subtitle;
final double value;
final ValueChanged<double>? onChanged;
final double min;
final double max;
final int? divisions;
const SettingsListSlider({
Key? key,
required this.title,
required this.trailing,
this.subtitle,
required this.value,
this.onChanged,
this.min = 0.0,
this.max = 1.0,
this.divisions,
}) : super(key: key);
static const _textStyle = TextStyle(fontWeight: FontWeight.w500);
@override
Widget build(BuildContext context) {
return ListTile(
contentPadding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
title: Row(
children: [
Text(title, style: _textStyle),
const Spacer(),
Text(trailing, style: _textStyle.copyWith(fontFeatures: [const FontFeature.tabularFigures()])),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Slider.adaptive(
value: value,
min: min,
max: max,
divisions: divisions,
onChanged: onChanged,
),
if (subtitle != null) Text(subtitle!),
],
),
);
}
}

View file

@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
/// A custom-styled adaptive [SwitchListTile].
class SettingsListSwitch extends StatelessWidget {
final String? title;
final Widget? subtitle;
final bool value;
final ValueChanged<bool>? onChanged;
const SettingsListSwitch({
Key? key,
this.title,
this.subtitle,
this.onChanged,
required this.value,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return SwitchListTile.adaptive(
isThreeLine: subtitle != null,
title: Text(title!, style: const TextStyle(fontWeight: FontWeight.w500)),
subtitle: subtitle,
value: value,
onChanged: onChanged,
);
}
}

View file

@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:frosty/widgets/app_bar.dart';
class SettingsTileRoute extends StatelessWidget {
final Widget leading;
final String title;
final Widget child;
const SettingsTileRoute({
Key? key,
required this.leading,
required this.title,
required this.child,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return ListTile(
leading: leading,
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w600)),
trailing: const Icon(Icons.chevron_right_rounded),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => Scaffold(
appBar: FrostyAppBar(title: Text(title)),
body: SafeArea(
bottom: false,
child: child,
),
),
),
),
);
}
}

View file

@ -3,13 +3,11 @@ import 'package:flutter/material.dart';
/// A simple widget that displays an alert message in the center.
class AlertMessage extends StatelessWidget {
final String message;
final IconData icon;
final Color? color;
const AlertMessage({
Key? key,
required this.message,
this.icon = Icons.info,
this.color,
}) : super(key: key);
@ -19,7 +17,7 @@ class AlertMessage extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
Icons.info_outline_rounded,
color: color ?? Colors.grey,
),
const SizedBox(width: 5.0),

View file

@ -26,8 +26,8 @@ class _AnimateScaleState extends State<AnimateScale> with SingleTickerProviderSt
@override
Widget build(BuildContext context) {
const duration = Duration(milliseconds: 200);
const downCurve = Curves.easeOutCubic;
const upCurve = Curves.easeInCubic;
const downCurve = Curves.easeOut;
const upCurve = Curves.easeIn;
return InkWell(
onTap: widget.onTap,

31
lib/widgets/app_bar.dart Normal file
View file

@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
class FrostyAppBar extends StatelessWidget with PreferredSizeWidget {
final Widget title;
final bool? centerTitle;
final List<Widget>? actions;
const FrostyAppBar({
Key? key,
required this.title,
this.centerTitle,
this.actions,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return AppBar(
leading: IconButton(
tooltip: 'Back',
icon: Icon(Icons.adaptive.arrow_back_rounded),
onPressed: Navigator.of(context).pop,
),
title: title,
centerTitle: centerTitle,
actions: actions,
);
}
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}

View file

@ -1,82 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/widgets/button.dart';
import 'package:frosty/widgets/dialog.dart';
class BlockButton extends StatelessWidget {
final AuthStore authStore;
final String targetUser;
final String targetUserId;
final bool simple;
const BlockButton({
Key? key,
required this.authStore,
required this.targetUser,
required this.targetUserId,
this.simple = true,
}) : super(key: key);
Future<void> _showDialog(
BuildContext context, {
required bool isBlocked,
required String targetUser,
required String targetUserId,
}) {
return showDialog(
context: context,
builder: (context) => FrostyDialog(
title: isBlocked ? 'Unblock' : 'Block',
content: Text(
'Are you sure you want to ${isBlocked ? 'unblock $targetUser?' : 'block $targetUser? This will remove them from channel lists, search results, and chat messages.'}'),
actions: [
Button(
onPressed: () {
if (isBlocked) {
authStore.user.unblock(targetId: targetUserId, headers: authStore.headersTwitch);
} else {
authStore.user.block(
targetId: targetUserId,
displayName: targetUser,
headers: authStore.headersTwitch,
);
}
Navigator.pop(context);
},
child: const Text('Yes'),
),
Button(
fill: true,
onPressed: Navigator.of(context).pop,
color: Colors.red.shade700,
child: const Text('Cancel'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Observer(
builder: (context) {
final isBlocked = authStore.user.blockedUsers.where((blockedUser) => blockedUser.userId == targetUserId).isNotEmpty;
return Button(
padding: const EdgeInsets.symmetric(horizontal: 15.0, vertical: 10.0),
icon: const Icon(Icons.block),
color: Colors.red.shade700,
fill: true,
onPressed: () => _showDialog(
context,
isBlocked: isBlocked,
targetUser: targetUser,
targetUserId: targetUserId,
),
child: isBlocked ? Text(simple ? 'Unblock' : 'Unblock $targetUser') : Text(simple ? 'Block' : 'Block $targetUser'),
);
},
);
}
}

View file

@ -1,10 +1,9 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/widgets/block_button.dart';
import 'package:frosty/widgets/modal.dart';
import 'package:frosty/widgets/report_button.dart';
import 'package:frosty/widgets/app_bar.dart';
import 'package:frosty/widgets/bottom_sheet.dart';
import 'package:frosty/widgets/list_tile.dart';
import 'package:webview_flutter/webview_flutter.dart';
class BlockReportModal extends StatelessWidget {
final AuthStore authStore;
@ -22,31 +21,38 @@ class BlockReportModal extends StatelessWidget {
@override
Widget build(BuildContext context) {
return FrostyModal(
return FrostyBottomSheet(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (authStore.isLoggedIn)
Container(
padding: const EdgeInsets.symmetric(horizontal: 10.0),
width: double.infinity,
child: BlockButton(
authStore: authStore,
targetUser: name,
targetUserId: userId,
simple: false,
FrostyListTile(
leading: const Icon(Icons.block_rounded),
onTap: () => authStore
.showBlockDialog(context, targetUser: name, targetUserId: userId)
.then((_) => Navigator.pop(context)),
title: 'Block $name',
),
FrostyListTile(
leading: const Icon(Icons.outlined_flag_rounded),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return Scaffold(
appBar: FrostyAppBar(
title: Text('Report $name'),
),
body: WebView(
initialUrl: 'https://www.twitch.tv/$userLogin/report',
javascriptMode: JavascriptMode.unrestricted,
),
);
},
),
),
const SizedBox(height: 10.0),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10.0),
width: double.infinity,
child: ReportButton(
userLogin: userLogin,
displayName: name,
),
),
if (Platform.isAndroid) const SizedBox(height: 20.0),
title: 'Report $name',
)
],
),
);

View file

@ -1,19 +1,18 @@
import 'package:flutter/material.dart';
class FrostyModal extends StatelessWidget {
class FrostyBottomSheet extends StatelessWidget {
final Widget child;
const FrostyModal({Key? key, required this.child}) : super(key: key);
const FrostyBottomSheet({Key? key, required this.child}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 15.0),
decoration: BoxDecoration(
color: Theme.of(context).dialogBackgroundColor,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(20.0),
topRight: Radius.circular(20.0),
topLeft: Radius.circular(15.0),
topRight: Radius.circular(15.0),
),
),
child: SafeArea(
@ -21,11 +20,11 @@ class FrostyModal extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
const FractionallySizedBox(
widthFactor: 0.25,
widthFactor: 0.2,
child: SizedBox(
child: Divider(
height: 25.0,
thickness: 3.0,
height: 30.0,
thickness: 2.0,
),
),
),

View file

@ -40,8 +40,8 @@ class _ButtonState extends State<Button> with SingleTickerProviderStateMixin {
backgroundColor: widget.color == null || widget.fill ? widget.color : Colors.transparent,
padding: widget.padding,
splashFactory: Platform.isIOS ? NoSplash.splashFactory : null,
textStyle: TextStyle(fontWeight: FontWeight.w600, fontSize: widget.fontSize),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(30.0)),
textStyle: DefaultTextStyle.of(context).style.copyWith(fontWeight: FontWeight.w600, fontSize: widget.fontSize),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10.0)),
elevation: widget.color == null ? 10.0 : 0.0,
).copyWith(
elevation: (widget.color == null || widget.fill) && widget.onPressed != null
@ -81,7 +81,7 @@ class _ButtonState extends State<Button> with SingleTickerProviderStateMixin {
if (mounted) {
_animationController.animateTo(
_animationController.upperBound,
curve: Curves.easeOutCubic,
curve: Curves.easeOut,
duration: duration,
);
}
@ -90,7 +90,7 @@ class _ButtonState extends State<Button> with SingleTickerProviderStateMixin {
if (mounted) {
_animationController.animateTo(
_animationController.lowerBound,
curve: Curves.easeInCubic,
curve: Curves.easeIn,
duration: duration,
);
}
@ -99,7 +99,7 @@ class _ButtonState extends State<Button> with SingleTickerProviderStateMixin {
if (mounted) {
_animationController.animateTo(
_animationController.lowerBound,
curve: Curves.easeInCubic,
curve: Curves.easeIn,
duration: duration,
);
}

View file

@ -0,0 +1,44 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/widgets.dart';
/// A wrapper around [CachedNetworkImage] that adds custom defaults for Frosty.
class FrostyCachedNetworkImage extends StatelessWidget {
final String imageUrl;
final double? width;
final double? height;
final Color? color;
final BlendMode? colorBlendMode;
final Widget Function(BuildContext, String)? placeholder;
final bool useFade;
final BoxFit? fit;
const FrostyCachedNetworkImage({
Key? key,
required this.imageUrl,
this.width,
this.height,
this.color,
this.colorBlendMode,
this.placeholder,
this.useFade = true,
this.fit,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return CachedNetworkImage(
imageUrl: imageUrl,
width: width,
height: height,
color: color,
colorBlendMode: colorBlendMode,
placeholder: placeholder,
useOldImageOnUrlChange: true,
fadeOutDuration: useFade ? const Duration(milliseconds: 500) : Duration.zero,
fadeInDuration: useFade ? const Duration(milliseconds: 500) : Duration.zero,
fadeInCurve: Curves.easeOut,
fadeOutCurve: Curves.easeIn,
fit: fit,
);
}
}

View file

@ -3,37 +3,40 @@ import 'package:flutter/material.dart';
/// A custom dialog that makes the title bold, puts the actions in a column, and adds a subtle border.
class FrostyDialog extends StatelessWidget {
final String title;
final Widget content;
final String? message;
final Widget? content;
final List<Widget>? actions;
const FrostyDialog({
Key? key,
this.actions,
required this.title,
required this.content,
this.message,
this.content,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return AlertDialog(
titlePadding: const EdgeInsets.fromLTRB(30.0, 30.0, 30.0, 20.0),
contentPadding: const EdgeInsets.fromLTRB(30.0, 0.0, 30.0, 30.0),
titlePadding: const EdgeInsets.fromLTRB(20.0, 30.0, 20.0, 20.0),
contentPadding: const EdgeInsets.fromLTRB(20.0, 0.0, 20.0, 20.0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20.0),
borderRadius: BorderRadius.circular(15.0),
),
title: Text(
title,
style: const TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
content: SingleChildScrollView(
child: Column(children: [
content,
content ?? Text(message!, textAlign: TextAlign.center),
if (actions != null) ...[
const SizedBox(height: 20.0),
const SizedBox(height: 25.0),
...?actions?.map(
(action) => Container(
width: double.infinity,
padding: const EdgeInsets.only(top: 10.0),
padding: const EdgeInsets.only(top: 5.0),
child: action,
),
),

View file

@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
class FrostyListTile extends StatelessWidget {
final bool? isThreeLine;
final Widget? leading;
final String title;
final Widget? subtitle;
final Widget? trailing;
final GestureTapCallback? onTap;
const FrostyListTile({
Key? key,
this.isThreeLine,
this.leading,
required this.title,
this.trailing,
this.onTap,
this.subtitle,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return ListTile(
isThreeLine: isThreeLine ?? subtitle != null,
leading: leading,
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: subtitle,
trailing: trailing,
onTap: onTap,
);
}
}

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:frosty/main.dart';
import 'package:frosty/widgets/button.dart';
class FrostyNotification extends StatelessWidget {
@ -17,8 +18,8 @@ class FrostyNotification extends StatelessWidget {
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.8),
borderRadius: BorderRadius.circular(30.0),
color: lightGray,
borderRadius: BorderRadius.circular(10.0),
),
margin: const EdgeInsets.all(10),
child: Row(
@ -29,7 +30,7 @@ class FrostyNotification extends StatelessWidget {
child: Row(
children: [
const Icon(
Icons.info,
Icons.info_outline_rounded,
color: Colors.white,
),
const SizedBox(width: 10),
@ -50,7 +51,7 @@ class FrostyNotification extends StatelessWidget {
if (showPasteButton)
Button(
onPressed: onButtonPressed,
fill: false,
color: purple,
child: const Text('Paste'),
),
],

View file

@ -0,0 +1,79 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:frosty/widgets/button.dart';
class FrostyPageView extends StatefulWidget {
final List<String> headers;
final List<Widget> children;
const FrostyPageView({
Key? key,
required this.headers,
required this.children,
}) : assert(headers.length == children.length),
super(key: key);
@override
State<FrostyPageView> createState() => _FrostyPageViewState();
}
class _FrostyPageViewState extends State<FrostyPageView> {
var currentIndex = 0;
late final PageController _pageContoller = PageController(initialPage: currentIndex);
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Divider(
height: 5.0,
thickness: 1.0,
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
...widget.headers.mapIndexed(
(index, section) => SizedBox(
height: 40,
child: Button(
padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 5.0),
onPressed: () {
setState(() => currentIndex = index);
_pageContoller.animateToPage(
index,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
},
color: index == currentIndex ? Theme.of(context).colorScheme.secondary : Colors.grey,
child: Text(
section,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
),
),
],
),
),
Expanded(
child: PageView.builder(
controller: _pageContoller,
itemBuilder: (context, index) => widget.children[index],
itemCount: widget.children.length,
),
),
],
);
}
@override
void dispose() {
_pageContoller.dispose();
super.dispose();
}
}

View file

@ -1,8 +1,9 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/main.dart';
import 'package:frosty/models/user.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/widgets/cached_image.dart';
import 'package:provider/provider.dart';
class ProfilePicture extends StatelessWidget {
@ -21,18 +22,26 @@ class ProfilePicture extends StatelessWidget {
return ClipOval(
child: FutureBuilder(
future: context.read<TwitchApi>().getUser(userLogin: userLogin, headers: context.read<AuthStore>().headersTwitch),
future:
context.read<TwitchApi>().getUser(userLogin: userLogin, headers: context.read<AuthStore>().headersTwitch),
builder: (context, AsyncSnapshot<UserTwitch> snapshot) {
return snapshot.hasData
? CachedNetworkImage(
width: diameter,
height: diameter,
imageUrl: snapshot.data!.profileImageUrl,
)
: SizedBox(
width: diameter,
height: diameter,
);
return AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: snapshot.hasData
? FrostyCachedNetworkImage(
width: diameter,
height: diameter,
imageUrl: snapshot.data!.profileImageUrl,
placeholder: (context, url) => const ColoredBox(color: lightGray),
)
: Container(
color: lightGray,
width: diameter,
height: diameter,
),
);
},
),
);

View file

@ -1,41 +0,0 @@
import 'package:flutter/material.dart';
import 'package:frosty/widgets/button.dart';
import 'package:webview_flutter/webview_flutter.dart';
class ReportButton extends StatelessWidget {
final String userLogin;
final String displayName;
const ReportButton({
Key? key,
required this.userLogin,
required this.displayName,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Button(
icon: const Icon(Icons.report),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return Scaffold(
appBar: AppBar(
title: const Text('Report'),
),
body: WebView(
initialUrl: 'https://www.twitch.tv/$userLogin/report',
javascriptMode: JavascriptMode.unrestricted,
),
);
},
),
),
padding: const EdgeInsets.symmetric(horizontal: 15.0, vertical: 10.0),
color: Colors.red.shade700,
fill: true,
child: Text('Report $displayName'),
);
}
}

View file

@ -16,11 +16,11 @@ class ScrollToTopButton extends StatelessWidget {
child: Button(
onPressed: () => scrollController.animateTo(
0.0,
curve: Curves.easeOutCubic,
curve: Curves.easeOut,
duration: const Duration(milliseconds: 500),
),
padding: const EdgeInsets.symmetric(horizontal: 25.0, vertical: 10.0),
child: const Icon(Icons.keyboard_arrow_up),
child: const Icon(Icons.keyboard_double_arrow_up_rounded),
),
),
);

View file

@ -1,40 +1,29 @@
import 'package:flutter/material.dart';
import 'package:frosty/main.dart';
class SectionHeader extends StatelessWidget {
final String text;
final EdgeInsets? padding;
final double? fontSize;
final bool showDivider;
const SectionHeader(
this.text, {
Key? key,
this.padding,
this.fontSize,
this.showDivider = false,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: padding ?? const EdgeInsets.fromLTRB(10.0, 20.0, 10.0, 5.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
text.toUpperCase(),
style: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.w600,
letterSpacing: 0.8,
),
),
if (showDivider)
const Divider(
height: 10.0,
thickness: 1.0,
),
],
padding: padding ?? const EdgeInsets.fromLTRB(16, 30, 16, 5),
child: Text(
text,
style: TextStyle(
color: purple,
fontSize: fontSize ?? 16,
fontWeight: FontWeight.w600,
),
),
);
}

View file

@ -392,13 +392,6 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
font_awesome_flutter:
dependency: "direct main"
description:
name: font_awesome_flutter
url: "https://pub.dartlang.org"
source: hosted
version: "10.2.1"
freezed_annotation:
dependency: transitive
description:
@ -854,6 +847,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.2"
simple_icons:
dependency: "direct main"
description:
name: simple_icons
url: "https://pub.dartlang.org"
source: hosted
version: "7.10.0"
simple_pip_mode:
dependency: "direct main"
description:

View file

@ -49,13 +49,13 @@ dependencies:
tuple: ^2.0.0
flutter_svg: ^1.0.0
url_launcher: ^6.0.17
font_awesome_flutter: ^10.0.0
package_info_plus: ^1.3.0
collection: ^1.16.0
flutter_cache_manager: ^3.3.0
device_info_plus: ^4.0.0
wakelock: ^0.6.2
device_preview: ^1.1.0
simple_icons: ^7.10.0
simple_pip_mode:
git:
url: https://github.com/tommyxchow/simple_pip_mode_flutter
@ -107,7 +107,6 @@ flutter:
assets:
- assets/icons/logo.svg
- assets/icons/TwitchGlitchWhite.png
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/assets-and-images/#resolution-aware.