Add first message highlights and various tweaks (#191)

* Add first msg & tweak chat message styles

* Add options to show/hide alerts

* Remove unused setting

* Fix color

* Change resume scroll arrrow

* Tweak streamer name and title opacity

* Tweak card weights

* Renamed followed streams tab

* Move settings stuff to details button

* Darken the dark theme again

* Remove unecessary observer

* Tweaks
This commit is contained in:
Tommy Chow 2022-09-06 12:04:31 -04:00 • committed by GitHub
parent d4e9d82ee2
commit f42ed2bc4e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 245 additions and 184 deletions

View file

@ -82,7 +82,7 @@ void main() async {
// Navigator key for sleep timer. Allows navigation popping without context.
final navigatorKey = GlobalKey<NavigatorState>();
const gray = Color.fromRGBO(22, 22, 22, 1.0);
const gray = Color.fromRGBO(18, 18, 18, 1.0);
const purple = Color(0xff9146ff);
const inputTheme = InputDecorationTheme(

View file

@ -17,10 +17,8 @@ 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_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:provider/provider.dart';
/// Creates a widget that shows the video stream (if live) and chat of the given user.
@ -73,65 +71,11 @@ class _VideoChatState extends State<VideoChat> {
Widget build(BuildContext context) {
final settingsStore = _chatStore.settings;
void showSettings() {
showModalBottomSheet(
backgroundColor: Colors.transparent,
context: context,
builder: (context) => FrostyModal(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.app_settings_alt),
title: const Text('App settings'),
onTap: () => 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),
),
),
),
),
ListTile(
leading: const Icon(Icons.refresh),
title: const Text('Reconnect to chat'),
onTap: () {
_chatStore.updateNotification('Reconnecting to chat...');
_chatStore.connectToChat();
},
),
ListTile(
leading: const Icon(Icons.refresh),
title: const Text('Refresh badges and emotes'),
onTap: () async {
await _chatStore.getAssets();
_chatStore.updateNotification('Badges and emotes refreshed');
},
),
],
),
),
);
}
final appBar = AppBar(
title: Text(
regexEnglish.hasMatch(_chatStore.displayName) ? _chatStore.displayName : '${_chatStore.displayName} (${_chatStore.channelName})',
style: const TextStyle(fontSize: 20),
),
actions: [
IconButton(
tooltip: 'Settings',
icon: const Icon(Icons.settings),
onPressed: showSettings,
),
],
);
final player = GestureDetector(
@ -144,7 +88,6 @@ class _VideoChatState extends State<VideoChat> {
final videoOverlay = VideoOverlay(
videoStore: _videoStore,
onSettingsPressed: showSettings,
);
final overlay = GestureDetector(

View file

@ -84,8 +84,8 @@ class Chat extends StatelessWidget {
: Button(
padding: const EdgeInsets.symmetric(horizontal: 15.0, vertical: 10.0),
onPressed: chatStore.resumeScroll,
icon: const Icon(Icons.arrow_circle_down),
child: const Text('Resume Scroll'),
icon: const Icon(Icons.arrow_downward),
child: const Text('Resume scroll'),
),
),
),

View file

@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.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/screens/settings/settings.dart';
import 'package:frosty/widgets/modal.dart';
class ChatDetails extends StatefulWidget {
@ -32,28 +32,71 @@ class _ChatDetailsState extends State<ChatDetails> {
@override
Widget build(BuildContext context) {
return FrostyModal(
child: SizedBox(
height: MediaQuery.of(context).size.height * 0.8,
child: GestureDetector(
onTap: FocusScope.of(context).unfocus,
child: Observer(
builder: (_) => Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 15.0),
child: ChatModes(roomState: widget.chatDetailsStore.roomState),
child: Column(
children: [
ListTile(
title: ChatModes(roomState: widget.chatDetailsStore.roomState),
),
const Divider(
height: 1.0,
thickness: 1.0,
),
ListTile(
leading: const Icon(Icons.settings),
title: const Text('Settings'),
onTap: () => showModalBottomSheet(
backgroundColor: Colors.transparent,
isScrollControlled: true,
context: context,
builder: (context) => FrostyModal(
child: SizedBox(
height: MediaQuery.of(context).size.height * 0.8,
child: Settings(settingsStore: widget.chatStore.settings),
),
Expanded(
child: ChattersList(
chatDetailsStore: widget.chatDetailsStore,
chatStore: widget.chatStore,
userLogin: widget.userLogin,
),
),
],
),
),
),
),
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,
),
),
),
),
),
),
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');
},
),
],
),
);
}

View file

@ -11,7 +11,7 @@ class ChatModes extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Tooltip(
preferBelow: false,

View file

@ -140,7 +140,7 @@ class ChatBottomBar extends StatelessWidget {
else
IconButton(
icon: Icon(Icons.adaptive.more),
tooltip: 'Chat details',
tooltip: 'More',
onPressed: () => showModalBottomSheet(
backgroundColor: Colors.transparent,
isScrollControlled: true,

View file

@ -50,6 +50,8 @@ class ChatMessage extends StatelessWidget {
chatStore.updateNotification('Message copied');
}
final defaultTextStyle = DefaultTextStyle.of(context).style;
return Observer(
builder: (context) {
Color? color;
@ -59,13 +61,14 @@ class ChatMessage extends StatelessWidget {
case Command.privateMessage:
case Command.userState:
// If user is being mentioned in the message, highlight it red.
if (ircMessage.mention == true) color = Colors.red.withOpacity(0.3);
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);
final messageSpan = Text.rich(
TextSpan(
children: ircMessage.generateSpan(
onLongPressName: onLongPressName,
style: DefaultTextStyle.of(context).style,
style: defaultTextStyle,
assetsStore: chatStore.assetsStore,
emoteScale: chatStore.settings.emoteScale,
badgeScale: chatStore.settings.badgeScale,
@ -82,32 +85,41 @@ 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) {
if ((replyUser != null && replyBody != null) || (chatStore.settings.highlightFirstTimeChatter && ircMessage.tags['first-msg'] == '1')) {
renderMessage = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Opacity(
opacity: 0.5,
child: Row(
children: [
Icon(
Icons.reply,
size: defaultBadgeSize * chatStore.settings.badgeScale,
),
const SizedBox(width: 5.0),
Flexible(
child: Tooltip(
message: 'Replying to @$replyUser: $replyBody',
preferBelow: false,
child: Text(
'Replying to @$replyUser: $replyBody',
maxLines: 1,
style: const TextStyle(overflow: TextOverflow.ellipsis),
),
),
),
],
),
Row(
children: [
Icon(
replyUser != null && replyBody != null ? Icons.reply : Icons.new_releases_outlined,
size: defaultBadgeSize * chatStore.settings.badgeScale,
color: defaultTextStyle.color?.withOpacity(0.5),
),
const SizedBox(width: 5.0),
Flexible(
child: replyUser != null && replyBody != null
? Tooltip(
message: 'Replying to @$replyUser: $replyBody',
preferBelow: false,
child: Text(
'Replying to @$replyUser: $replyBody',
maxLines: 1,
style: TextStyle(
overflow: TextOverflow.ellipsis,
color: defaultTextStyle.color?.withOpacity(0.5),
),
),
)
: Text(
'First time chatting',
style: TextStyle(
fontWeight: FontWeight.w600,
color: defaultTextStyle.color?.withOpacity(0.5),
),
),
),
],
),
const SizedBox(height: 5.0),
messageSpan,
@ -124,15 +136,32 @@ class ChatMessage extends StatelessWidget {
final banDuration = ircMessage.tags['ban-duration'];
renderMessage = Opacity(
opacity: 0.5,
opacity: 0.4,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (banDuration == null)
if (ircMessage.command == Command.clearMessage)
const Text(
'Message deleted',
style: TextStyle(fontWeight: FontWeight.w600),
)
else
const Text(
'Permanently banned',
style: TextStyle(fontWeight: FontWeight.w600),
)
else
Text(
'Timed out for $banDuration ${int.parse(banDuration) > 1 ? 'seconds' : 'second'}',
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 5.0),
Text.rich(
TextSpan(
children: ircMessage.generateSpan(
onLongPressName: onLongPressName,
style: DefaultTextStyle.of(context).style,
style: defaultTextStyle,
assetsStore: chatStore.assetsStore,
emoteScale: chatStore.settings.emoteScale,
badgeScale: chatStore.settings.badgeScale,
@ -145,23 +174,6 @@ class ChatMessage extends StatelessWidget {
),
),
),
const SizedBox(height: 5.0),
if (banDuration == null)
if (ircMessage.command == Command.clearMessage)
const Text(
'Message deleted',
style: TextStyle(fontWeight: FontWeight.w600),
)
else
const Text(
'User permanently banned',
style: TextStyle(fontWeight: FontWeight.w600),
)
else
Text(
'Timed out for $banDuration ${int.parse(banDuration) > 1 ? 'seconds' : 'second'}',
style: const TextStyle(fontWeight: FontWeight.w600),
)
],
),
);
@ -169,51 +181,58 @@ class ChatMessage extends StatelessWidget {
case Command.notice:
renderMessage = Text.rich(
TextSpan(text: ircMessage.message),
style: TextStyle(color: Theme.of(context).textTheme.bodyText2?.color?.withOpacity(0.5)),
style: TextStyle(color: defaultTextStyle.color?.withOpacity(0.5)),
);
break;
case Command.userNotice:
color = Colors.deepPurple.withOpacity(0.3);
if (chatStore.settings.showUserNotices) {
color = Colors.deepPurple.withOpacity(0.2);
renderMessage = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (ircMessage.tags.containsKey('system-msg'))
Text(
ircMessage.tags['system-msg']!,
style: const TextStyle(fontWeight: FontWeight.w600),
),
if (ircMessage.tags.containsKey('msg-id') && ircMessage.tags['msg-id'] == 'announcement')
Row(
children: const [
Icon(Icons.announcement),
SizedBox(width: 5.0),
Text(
'Announcement',
style: TextStyle(fontWeight: FontWeight.w600),
),
],
),
const SizedBox(height: 5.0),
if (ircMessage.message != null)
Text.rich(
TextSpan(
children: ircMessage.generateSpan(
onLongPressName: onLongPressName,
style: DefaultTextStyle.of(context).style,
assetsStore: chatStore.assetsStore,
emoteScale: chatStore.settings.emoteScale,
badgeScale: chatStore.settings.badgeScale,
useZeroWidth: chatStore.settings.showZeroWidth,
useReadableColors: chatStore.settings.useReadableColors,
isLightTheme: Theme.of(context).brightness == Brightness.light,
launchExternal: chatStore.settings.launchUrlExternal,
timestamp: chatStore.settings.timestampType,
renderMessage = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (ircMessage.tags.containsKey('system-msg'))
Text(
ircMessage.tags['system-msg']!,
style: TextStyle(fontWeight: FontWeight.w600, color: defaultTextStyle.color?.withOpacity(0.5)),
),
if (ircMessage.tags['msg-id'] == 'announcement')
Row(
children: [
Icon(
Icons.announcement_outlined,
size: defaultBadgeSize * chatStore.settings.badgeScale,
),
const SizedBox(width: 5.0),
const Text(
'Announcement',
style: TextStyle(fontWeight: FontWeight.w600),
),
],
),
const SizedBox(height: 5.0),
if (ircMessage.message != null)
Text.rich(
TextSpan(
children: ircMessage.generateSpan(
onLongPressName: onLongPressName,
style: defaultTextStyle,
assetsStore: chatStore.assetsStore,
emoteScale: chatStore.settings.emoteScale,
badgeScale: chatStore.settings.badgeScale,
useZeroWidth: chatStore.settings.showZeroWidth,
useReadableColors: chatStore.settings.useReadableColors,
isLightTheme: Theme.of(context).brightness == Brightness.light,
launchExternal: chatStore.settings.launchUrlExternal,
timestamp: chatStore.settings.timestampType,
),
),
),
),
],
);
],
);
} else {
renderMessage = const SizedBox();
}
break;
default:
renderMessage = const SizedBox();

View file

@ -14,12 +14,10 @@ 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;
const VideoOverlay({
Key? key,
required this.videoStore,
required this.onSettingsPressed,
}) : super(key: key);
Future<void> _showSleepTimerDialog(BuildContext context) {
@ -108,15 +106,6 @@ class VideoOverlay extends StatelessWidget {
),
);
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',
@ -196,7 +185,6 @@ class VideoOverlay extends StatelessWidget {
backButton,
const Spacer(),
if (videoStore.settingsStore.fullScreen && orientation == Orientation.landscape) chatOverlayButton,
settingsButton,
],
),
Align(
@ -251,7 +239,6 @@ class VideoOverlay extends StatelessWidget {
const Spacer(),
if (videoStore.settingsStore.fullScreen && orientation == Orientation.landscape) chatOverlayButton,
sleepTimerButton,
settingsButton,
],
),

View file

@ -82,7 +82,7 @@ class _HomeState extends State<Home> {
title: Observer(
builder: (_) {
final titles = [
if (_authStore.isLoggedIn) 'Followed Streams',
if (_authStore.isLoggedIn) 'Following',
'Top',
'Search',
];
@ -139,7 +139,7 @@ class _HomeState extends State<Home> {
if (_authStore.isLoggedIn)
const BottomNavigationBarItem(
icon: Icon(Icons.favorite),
label: 'Followed',
label: 'Following',
tooltip: 'Followed streams',
),
const BottomNavigationBarItem(

View file

@ -129,7 +129,7 @@ class StreamCard extends StatelessWidget {
streamerName,
style: TextStyle(
fontSize: large ? 20.0 : 16.0,
fontWeight: FontWeight.bold,
fontWeight: FontWeight.w600,
color: fontColor,
),
overflow: TextOverflow.ellipsis,
@ -147,9 +147,8 @@ class StreamCard extends StatelessWidget {
streamInfo.title.trim(),
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w500,
fontSize: subFontSize,
color: fontColor,
color: fontColor?.withOpacity(0.8),
),
),
),

View file

@ -55,7 +55,7 @@ class CategoryCard extends StatelessWidget {
padding: const EdgeInsets.all(10.0),
child: Text(
category.name,
style: const TextStyle(fontWeight: FontWeight.bold),
style: const TextStyle(fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis,
),
),

View file

@ -217,6 +217,23 @@ class _ChatSettingsState extends State<ChatSettings> {
.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,

View file

@ -103,6 +103,9 @@ abstract class _SettingsStoreBase with Store {
static const defaultShowChatMessageDividers = false;
static const defaultTimestampType = TimestampType.disabled;
static const defaultHighlightFirstTimeChatter = true;
static const defaultShowUserNotices = true;
static const defaultBadgeScale = 1.0;
static const defaultEmoteScale = 1.0;
static const defaultMessageScale = 1.0;
@ -165,6 +168,14 @@ abstract class _SettingsStoreBase with Store {
@observable
var timestampType = defaultTimestampType;
@JsonKey(defaultValue: defaultHighlightFirstTimeChatter)
@observable
var highlightFirstTimeChatter = defaultHighlightFirstTimeChatter;
@JsonKey(defaultValue: defaultShowUserNotices)
@observable
var showUserNotices = defaultHighlightFirstTimeChatter;
@JsonKey(defaultValue: defaultBadgeScale)
@observable
var badgeScale = defaultBadgeScale;
@ -201,6 +212,8 @@ abstract class _SettingsStoreBase with Store {
showDeletedMessages = defaultShowDeletedMessages;
showChatMessageDividers = defaultShowChatMessageDividers;
timestampType = defaultTimestampType;
highlightFirstTimeChatter = defaultHighlightFirstTimeChatter;
showUserNotices = defaultShowUserNotices;
badgeScale = defaultBadgeScale;
emoteScale = defaultEmoteScale;
messageScale = defaultMessageScale;

View file

@ -42,6 +42,9 @@ SettingsStore _$SettingsStoreFromJson(Map<String, dynamic> json) =>
_$TimestampTypeEnumMap, json['timestampType'],
unknownValue: TimestampType.disabled) ??
TimestampType.disabled
..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
@ -79,6 +82,8 @@ Map<String, dynamic> _$SettingsStoreToJson(SettingsStore instance) =>
'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,
@ -507,6 +512,39 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
});
}
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);
@ -748,6 +786,8 @@ useReadableColors: ${useReadableColors},
showDeletedMessages: ${showDeletedMessages},
showChatMessageDividers: ${showChatMessageDividers},
timestampType: ${timestampType},
highlightFirstTimeChatter: ${highlightFirstTimeChatter},
showUserNotices: ${showUserNotices},
badgeScale: ${badgeScale},
emoteScale: ${emoteScale},
messageScale: ${messageScale},