Add status notices to chat, pause & resume test

This commit is contained in:
tommyxchow 2021-12-13 02:19:06 -05:00
parent cb9814047f
commit 04f0ef74d3
4 changed files with 167 additions and 130 deletions

View file

@ -105,6 +105,8 @@ class IRCMessage {
message: message,
);
}
factory IRCMessage.createNotice({required String message}) => IRCMessage(raw: '', tags: {}, command: Command.notice, user: null, message: 'FROSTY: $message');
}
/// The object representation of the IRC ROOMSTATE message.

View file

@ -16,134 +16,138 @@ class Chat extends StatefulWidget {
_ChatState createState() => _ChatState();
}
class _ChatState extends State<Chat> {
class _ChatState extends State<Chat> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance!.addObserver(this);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
widget.chatStore.handleAppStateChange(state);
}
@override
Widget build(BuildContext context) {
final chatStore = widget.chatStore;
return FutureBuilder(
future: chatStore.getAssets(),
builder: (_, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return GestureDetector(
onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
child: Observer(
builder: (context) => Column(
return GestureDetector(
onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
child: Observer(
builder: (context) => Column(
children: [
Expanded(
child: Stack(
alignment: AlignmentDirectional.bottomCenter,
children: [
Expanded(
child: Stack(
alignment: AlignmentDirectional.bottomCenter,
children: [
Observer(
builder: (_) => ListView.builder(
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
itemCount: chatStore.messages.length,
controller: chatStore.scrollController,
itemBuilder: (context, index) {
return chatStore.renderChatMessage(chatStore.messages[index], context);
},
),
),
Observer(
builder: (_) {
return Visibility(
visible: !chatStore.autoScroll,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
width: double.infinity,
child: ElevatedButton(
onPressed: chatStore.resumeScroll,
child: const Text('Resume Scroll'),
),
),
);
},
),
],
Observer(
builder: (_) => ListView.builder(
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
itemCount: chatStore.messages.length,
controller: chatStore.scrollController,
itemBuilder: (context, index) {
return chatStore.renderChatMessage(chatStore.messages[index], context);
},
),
),
if (context.read<AuthStore>().isLoggedIn)
Row(
children: [
IconButton(
icon: Icon(Icons.adaptive.more),
onPressed: () {
showModalBottomSheet(
context: context,
builder: (context) {
return ChatStats(
chatStore: chatStore,
);
},
);
},
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 5.0, horizontal: 10.0),
child: TextField(
minLines: 1,
maxLines: 5,
onTap: () => chatStore.showEmoteMenu = false,
decoration: InputDecoration(
suffixIcon: IconButton(
icon: const Icon(Icons.emoji_emotions_outlined),
onPressed: () {
FocusManager.instance.primaryFocus?.unfocus();
chatStore.showEmoteMenu = !chatStore.showEmoteMenu;
},
),
isDense: true,
contentPadding: const EdgeInsets.all(10.0),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(10.0)),
),
hintText: 'Send a message',
),
controller: chatStore.textController,
onSubmitted: chatStore.sendMessage,
),
Observer(
builder: (_) {
return Visibility(
visible: !chatStore.autoScroll,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
width: double.infinity,
child: ElevatedButton(
onPressed: chatStore.resumeScroll,
child: const Text('Resume Scroll'),
),
),
IconButton(
icon: const Icon(Icons.send),
onPressed: () => chatStore.sendMessage(chatStore.textController.text),
)
],
),
if (chatStore.showEmoteMenu) ...[
Observer(builder: (_) => EmoteMenu(chatStore: chatStore, emoteType: EmoteType.values[chatStore.emoteMenuIndex])),
Observer(
builder: (_) => SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: EmoteType.values.asMap().entries.map((e) {
final emotes = chatStore.emoteToObject.values.toList().where((emote) => emote.type == EmoteType.values[e.key]).toList();
return TextButton(
style: e.key == chatStore.emoteMenuIndex ? null : TextButton.styleFrom(primary: Colors.grey),
onPressed: emotes.isEmpty ? null : () => chatStore.emoteMenuIndex = e.key,
child: Text(chatStore.emoteMenuTitle(e.value)),
);
}).toList(),
),
),
)
]
);
},
),
],
),
),
);
}
return const Center(
child: CircularProgressIndicator.adaptive(),
);
},
if (context.read<AuthStore>().isLoggedIn)
Row(
children: [
IconButton(
icon: Icon(Icons.adaptive.more),
onPressed: () {
showModalBottomSheet(
context: context,
builder: (context) {
return ChatStats(
chatStore: chatStore,
);
},
);
},
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 5.0, horizontal: 10.0),
child: TextField(
minLines: 1,
maxLines: 5,
onTap: () => chatStore.showEmoteMenu = false,
decoration: InputDecoration(
suffixIcon: IconButton(
icon: const Icon(Icons.emoji_emotions_outlined),
onPressed: () {
FocusManager.instance.primaryFocus?.unfocus();
chatStore.showEmoteMenu = !chatStore.showEmoteMenu;
},
),
isDense: true,
contentPadding: const EdgeInsets.all(10.0),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(10.0)),
),
hintText: 'Send a message',
),
controller: chatStore.textController,
onSubmitted: chatStore.sendMessage,
),
),
),
IconButton(
icon: const Icon(Icons.send),
onPressed: () => chatStore.sendMessage(chatStore.textController.text),
)
],
),
if (chatStore.showEmoteMenu) ...[
Observer(builder: (_) => EmoteMenu(chatStore: chatStore, emoteType: EmoteType.values[chatStore.emoteMenuIndex])),
Observer(
builder: (_) => SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: EmoteType.values.asMap().entries.map((e) {
final emotes = chatStore.emoteToObject.values.toList().where((emote) => emote.type == EmoteType.values[e.key]).toList();
return TextButton(
style: e.key == chatStore.emoteMenuIndex ? null : TextButton.styleFrom(primary: Colors.grey),
onPressed: emotes.isEmpty ? null : () => chatStore.emoteMenuIndex = e.key,
child: Text(chatStore.emoteMenuTitle(e.value)),
);
}).toList(),
),
),
)
]
],
),
),
);
}
@override
void dispose() {
WidgetsBinding.instance!.removeObserver(this);
widget.chatStore.dispose();
super.dispose();
}

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:frosty/api/bttv_api.dart';
@ -22,6 +24,9 @@ abstract class _ChatStoreBase with Store {
/// The Twitch IRC WebSocket channel.
final _channel = WebSocketChannel.connect(Uri.parse('wss://irc-ws.chat.twitch.tv:443'));
/// The stream subscription that is responsible for handling events from the chat.
StreamSubscription? _subscription;
/// The map of emote words to their image or GIF URL.
final _emoteToObject = <String, Emote>{};
Map<String, Emote> get emoteToObject => _emoteToObject;
@ -75,10 +80,16 @@ abstract class _ChatStoreBase with Store {
required this.settings,
required this.channelName,
}) {
messages.add(IRCMessage.createNotice(message: 'Connecting to chat...'));
getAssets();
// Listen for new messages and forward them to the handler.
_channel.stream.listen(
_subscription = _channel.stream.listen(
(data) => _handleIRCData(data.toString()),
onError: (error) => debugPrint('Failed to connect to chat: ${error.toString()}'),
onError: (error) {
debugPrint('Failed to connect to chat: ${error.toString()}');
messages.add(IRCMessage.createNotice(message: 'Failed to connect to chat, please try again.'));
},
onDone: () => debugPrint("Disconnected from $channelName's chat."),
);
@ -103,6 +114,8 @@ abstract class _ChatStoreBase with Store {
_channel.sink.add(command);
}
messages.add(IRCMessage.createNotice(message: "Connected to $channelName's chat."));
// Tell the scrollController to determine when auto-scroll should be enabled or disabled.
scrollController.addListener(() {
// If the user scrolls up, auto-scroll will stop, allowing them to freely scroll back to previous messages.
@ -230,6 +243,7 @@ abstract class _ChatStoreBase with Store {
/// Fetches global and channel assets (badges and emotes) and stores them in [_emoteToUrl]
Future<void> getAssets() async {
messages.add(IRCMessage.createNotice(message: 'Fetching channel assets...'));
// Fetch the desired channel/user's information.
final channelInfo = await Twitch.getUser(userLogin: channelName, headers: auth.headersTwitch);
@ -237,20 +251,20 @@ abstract class _ChatStoreBase with Store {
// Fetch the global and channel's assets (emotes & badges).
// Async awaits are placed in a list so they are performed in parallel.
final assets = [
await FFZ.getEmotesGlobal(),
await FFZ.getEmotesChannel(id: channelInfo.id),
await BTTV.getEmotesGlobal(),
await BTTV.getEmotesChannel(id: channelInfo.id),
await Twitch.getEmotesGlobal(headers: auth.headersTwitch),
await Twitch.getEmotesChannel(id: channelInfo.id, headers: auth.headersTwitch),
await SevenTV.getEmotesGlobal(),
await SevenTV.getEmotesChannel(user: channelInfo.login)
...await FFZ.getEmotesGlobal(),
...await FFZ.getEmotesChannel(id: channelInfo.id),
...await BTTV.getEmotesGlobal(),
...await BTTV.getEmotesChannel(id: channelInfo.id),
...await Twitch.getEmotesGlobal(headers: auth.headersTwitch),
...await Twitch.getEmotesChannel(id: channelInfo.id, headers: auth.headersTwitch),
...await SevenTV.getEmotesGlobal(),
...await SevenTV.getEmotesChannel(user: channelInfo.login)
];
for (final emotes in assets) {
for (var emote in emotes) {
_emoteToObject[emote.name] = emote;
}
assets.sort((a, b) => a.id.compareTo(b.id));
for (final emote in assets) {
_emoteToObject[emote.name] = emote;
}
final badges = [
@ -263,6 +277,8 @@ abstract class _ChatStoreBase with Store {
_badgesToObject.addAll(map);
}
}
messages.add(IRCMessage.createNotice(message: 'Channel assets fetched!'));
}
}
@ -372,6 +388,7 @@ abstract class _ChatStoreBase with Store {
}
}
/// Returns the readable text for the given emote type.
String emoteMenuTitle(EmoteType type) {
switch (type) {
case EmoteType.twitchGlobal:
@ -395,9 +412,26 @@ abstract class _ChatStoreBase with Store {
}
}
/// Pauses or resumes the chat subscription depending on the provided state.
void handleAppStateChange(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.resumed:
_subscription?.resume();
break;
case AppLifecycleState.inactive:
break;
case AppLifecycleState.paused:
_subscription?.pause();
break;
case AppLifecycleState.detached:
break;
}
}
/// Closes and disposes all the channels and controllers used by the store.
void dispose() {
_channel.sink.close();
_subscription?.cancel();
textController.dispose();
scrollController.dispose();
}

View file

@ -6,10 +6,7 @@ import 'package:frosty/screens/search/search_store.dart';
class Search extends StatefulWidget {
final SearchStore searchStore;
const Search({
Key? key,
required this.searchStore,
}) : super(key: key);
const Search({Key? key, required this.searchStore}) : super(key: key);
@override
_SearchState createState() => _SearchState();