diff --git a/lib/models/irc.dart b/lib/models/irc.dart index 19b0db83..9ee192d2 100644 --- a/lib/models/irc.dart +++ b/lib/models/irc.dart @@ -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. diff --git a/lib/screens/channel/chat/chat.dart b/lib/screens/channel/chat/chat.dart index 06452c9f..19489c2f 100644 --- a/lib/screens/channel/chat/chat.dart +++ b/lib/screens/channel/chat/chat.dart @@ -16,134 +16,138 @@ class Chat extends StatefulWidget { _ChatState createState() => _ChatState(); } -class _ChatState extends State { +class _ChatState extends State 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().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().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(); } diff --git a/lib/screens/channel/chat/chat_store.dart b/lib/screens/channel/chat/chat_store.dart index c902b4ab..c96bc6b5 100644 --- a/lib/screens/channel/chat/chat_store.dart +++ b/lib/screens/channel/chat/chat_store.dart @@ -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 = {}; Map 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 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(); } diff --git a/lib/screens/search/search.dart b/lib/screens/search/search.dart index 0e733544..d008b27a 100644 --- a/lib/screens/search/search.dart +++ b/lib/screens/search/search.dart @@ -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();