Add status notices to chat, pause & resume test
This commit is contained in:
parent
cb9814047f
commit
04f0ef74d3
4 changed files with 167 additions and 130 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue