From ec77d0e29aa6ae831cb4c6932f437fa10e9fe8f9 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Wed, 19 Jan 2022 23:01:31 -0500 Subject: [PATCH] Add recent emotes section to emote menu --- lib/models/emotes.dart | 4 + lib/models/emotes.g.dart | 32 +++++ .../channel/chat/emote_menu/emote_menu.dart | 110 +++++++++++------- .../chat/emote_menu/emote_menu_panel.dart | 13 ++- .../chat/emote_menu/emote_menu_section.dart | 25 ++-- .../chat/emote_menu/recent_emotes_panel.dart | 72 ++++++++++++ .../channel/stores/chat_assets_store.dart | 24 ++++ .../channel/stores/chat_assets_store.g.dart | 24 ++++ lib/screens/channel/stores/chat_store.dart | 3 + .../settings/stores/settings_store.dart | 1 - 10 files changed, 256 insertions(+), 52 deletions(-) create mode 100644 lib/screens/channel/chat/emote_menu/recent_emotes_panel.dart diff --git a/lib/models/emotes.dart b/lib/models/emotes.dart index 54dc30fb..dbb8a74f 100644 --- a/lib/models/emotes.dart +++ b/lib/models/emotes.dart @@ -257,6 +257,7 @@ class Emote7TV { } /// The common emote class. +@JsonSerializable() class Emote { final String name; final int? width; @@ -303,6 +304,9 @@ class Emote { url: emote.urls[3][1], type: type, ); + + factory Emote.fromJson(Map json) => _$EmoteFromJson(json); + Map toJson() => _$EmoteToJson(this); } enum EmoteType { diff --git a/lib/models/emotes.g.dart b/lib/models/emotes.g.dart index cc774312..b685dc19 100644 --- a/lib/models/emotes.g.dart +++ b/lib/models/emotes.g.dart @@ -123,3 +123,35 @@ Emote7TV _$Emote7TVFromJson(Map json) => Emote7TV( .map((e) => (e as List).map((e) => e as String).toList()) .toList(), ); + +Emote _$EmoteFromJson(Map json) => Emote( + name: json['name'] as String, + width: json['width'] as int?, + height: json['height'] as int?, + zeroWidth: json['zeroWidth'] as bool, + url: json['url'] as String, + type: $enumDecode(_$EmoteTypeEnumMap, json['type']), + ); + +Map _$EmoteToJson(Emote instance) => { + 'name': instance.name, + 'width': instance.width, + 'height': instance.height, + 'zeroWidth': instance.zeroWidth, + 'url': instance.url, + 'type': _$EmoteTypeEnumMap[instance.type], + }; + +const _$EmoteTypeEnumMap = { + EmoteType.twitchSub: 'twitchSub', + EmoteType.twitchGlobal: 'twitchGlobal', + EmoteType.twitchUnlocked: 'twitchUnlocked', + EmoteType.twitchChannel: 'twitchChannel', + EmoteType.ffzGlobal: 'ffzGlobal', + EmoteType.ffzChannel: 'ffzChannel', + EmoteType.bttvGlobal: 'bttvGlobal', + EmoteType.bttvChannel: 'bttvChannel', + EmoteType.bttvShared: 'bttvShared', + EmoteType.sevenTVGlobal: 'sevenTVGlobal', + EmoteType.sevenTVChannel: 'sevenTVChannel', +}; diff --git a/lib/screens/channel/chat/emote_menu/emote_menu.dart b/lib/screens/channel/chat/emote_menu/emote_menu.dart index 147cbcf0..92812784 100644 --- a/lib/screens/channel/chat/emote_menu/emote_menu.dart +++ b/lib/screens/channel/chat/emote_menu/emote_menu.dart @@ -2,63 +2,47 @@ 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/stores/chat_assets_store.dart'; -class EmoteMenu extends StatelessWidget { +class EmoteMenu extends StatefulWidget { final ChatAssetsStore assetsStore; final TextEditingController textController; - const EmoteMenu({Key? key, required this.assetsStore, required this.textController}) : super(key: key); + const EmoteMenu({ + Key? key, + required this.assetsStore, + required this.textController, + }) : super(key: key); + + @override + State createState() => _EmoteMenuState(); +} + +class _EmoteMenuState extends State { + late final PageController pageContoller; + + @override + void initState() { + pageContoller = PageController(initialPage: widget.assetsStore.emoteMenuIndex); + super.initState(); + } @override Widget build(BuildContext context) { const sections = [ + "Recent", "Twitch", "BTTV", "FFZ", "7TV", ]; - final pageContoller = PageController(); - return Column( children: [ - Expanded( - child: PageView( - onPageChanged: (index) => assetsStore.emoteMenuIndex = index, - controller: pageContoller, - children: [ - Observer( - builder: (_) => EmoteMenuPanel( - textController: textController, - emotes: assetsStore.userEmoteToObject.values.toList(), - ), - ), - Observer( - builder: (_) => EmoteMenuPanel( - textController: textController, - emotes: assetsStore.bttvEmotes, - ), - ), - Observer( - builder: (_) => EmoteMenuPanel( - textController: textController, - emotes: assetsStore.ffzEmotes, - ), - ), - Observer( - builder: (_) => EmoteMenuPanel( - textController: textController, - emotes: assetsStore.sevenTVEmotes, - ), - ), - ], - ), - ), SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( - mainAxisAlignment: MainAxisAlignment.center, children: sections .mapIndexed( (index, section) => Observer( @@ -66,12 +50,12 @@ class EmoteMenu extends StatelessWidget { onPressed: () { pageContoller.animateToPage( index, - duration: const Duration(milliseconds: 300), + duration: const Duration(milliseconds: 200), curve: Curves.ease, ); - assetsStore.emoteMenuIndex = index; + widget.assetsStore.emoteMenuIndex = index; }, - style: index == assetsStore.emoteMenuIndex ? null : TextButton.styleFrom(primary: Colors.grey), + style: index == widget.assetsStore.emoteMenuIndex ? null : TextButton.styleFrom(primary: Colors.grey), child: Text(section), ), ), @@ -79,7 +63,53 @@ class EmoteMenu extends StatelessWidget { .toList(), ), ), + Expanded( + child: PageView( + onPageChanged: (index) => widget.assetsStore.emoteMenuIndex = index, + controller: pageContoller, + children: [ + RecentEmotesPanel( + assetsStore: widget.assetsStore, + textController: widget.textController, + ), + Observer( + builder: (_) => EmoteMenuPanel( + assetsStore: widget.assetsStore, + textController: widget.textController, + emotes: widget.assetsStore.userEmoteToObject.values.toList(), + ), + ), + Observer( + builder: (_) => EmoteMenuPanel( + assetsStore: widget.assetsStore, + textController: widget.textController, + emotes: widget.assetsStore.bttvEmotes, + ), + ), + Observer( + builder: (_) => EmoteMenuPanel( + assetsStore: widget.assetsStore, + textController: widget.textController, + emotes: widget.assetsStore.ffzEmotes, + ), + ), + Observer( + builder: (_) => EmoteMenuPanel( + assetsStore: widget.assetsStore, + textController: widget.textController, + emotes: widget.assetsStore.sevenTVEmotes, + ), + ), + ], + ), + ), ], ); } + + @override + void dispose() { + pageContoller.dispose(); + super.dispose(); + } } diff --git a/lib/screens/channel/chat/emote_menu/emote_menu_panel.dart b/lib/screens/channel/chat/emote_menu/emote_menu_panel.dart index 2b58d7fa..e06afe6b 100644 --- a/lib/screens/channel/chat/emote_menu/emote_menu_panel.dart +++ b/lib/screens/channel/chat/emote_menu/emote_menu_panel.dart @@ -1,13 +1,20 @@ 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/stores/chat_assets_store.dart'; import 'package:frosty/widgets/section_header.dart'; class EmoteMenuPanel extends StatelessWidget { + final ChatAssetsStore assetsStore; final TextEditingController textController; final List emotes; - const EmoteMenuPanel({Key? key, required this.textController, required this.emotes}) : super(key: key); + const EmoteMenuPanel({ + Key? key, + required this.assetsStore, + required this.textController, + required this.emotes, + }) : super(key: key); @override Widget build(BuildContext context) { @@ -41,6 +48,7 @@ class EmoteMenuPanel extends StatelessWidget { ), ), EmoteMenuSection( + assetsStore: assetsStore, textController: textController, emotes: globalEmotes, ), @@ -50,6 +58,7 @@ class EmoteMenuPanel extends StatelessWidget { child: SectionHeader('Channel Emotes'), ), EmoteMenuSection( + assetsStore: assetsStore, textController: textController, emotes: channelEmotes, ), @@ -59,6 +68,7 @@ class EmoteMenuPanel extends StatelessWidget { child: SectionHeader('Subbed Emotes'), ), EmoteMenuSection( + assetsStore: assetsStore, textController: textController, emotes: subEmotes, ), @@ -68,6 +78,7 @@ class EmoteMenuPanel extends StatelessWidget { child: SectionHeader('Unlocked Emotes'), ), EmoteMenuSection( + assetsStore: assetsStore, textController: textController, emotes: miscEmotes, ), diff --git a/lib/screens/channel/chat/emote_menu/emote_menu_section.dart b/lib/screens/channel/chat/emote_menu/emote_menu_section.dart index 6664eac1..24042135 100644 --- a/lib/screens/channel/chat/emote_menu/emote_menu_section.dart +++ b/lib/screens/channel/chat/emote_menu/emote_menu_section.dart @@ -1,15 +1,18 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:frosty/models/emotes.dart'; +import 'package:frosty/screens/channel/stores/chat_assets_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:provider/provider.dart'; class EmoteMenuSection extends StatelessWidget { + final ChatAssetsStore assetsStore; final TextEditingController textController; final List emotes; const EmoteMenuSection({ Key? key, + required this.assetsStore, required this.textController, required this.emotes, }) : super(key: key); @@ -29,16 +32,18 @@ class EmoteMenuSection extends StatelessWidget { crossAxisSpacing: 10.0, ), delegate: SliverChildBuilderDelegate( - (context, index) { - return GestureDetector( - onTap: () => textController.text += ' ' + emotes[index].name, - child: Tooltip( - message: emotes[index].name, - preferBelow: false, - child: CachedNetworkImage(imageUrl: emotes[index].url), - ), - ); - }, + (context, index) => GestureDetector( + onTap: () { + textController.text += ' ' + emotes[index].name; + assetsStore.recentEmotes.remove(emotes[index]); + assetsStore.recentEmotes.insert(0, emotes[index]); + }, + child: Tooltip( + message: emotes[index].name, + preferBelow: false, + child: CachedNetworkImage(imageUrl: emotes[index].url), + ), + ), childCount: emotes.length, ), ), diff --git a/lib/screens/channel/chat/emote_menu/recent_emotes_panel.dart b/lib/screens/channel/chat/emote_menu/recent_emotes_panel.dart new file mode 100644 index 00000000..ba6db79f --- /dev/null +++ b/lib/screens/channel/chat/emote_menu/recent_emotes_panel.dart @@ -0,0 +1,72 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:frosty/screens/channel/stores/chat_assets_store.dart'; +import 'package:frosty/screens/settings/stores/settings_store.dart'; +import 'package:frosty/widgets/section_header.dart'; +import 'package:provider/provider.dart'; + +class RecentEmotesPanel extends StatelessWidget { + final ChatAssetsStore assetsStore; + final TextEditingController textController; + + const RecentEmotesPanel({ + Key? key, + required this.assetsStore, + required this.textController, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return CustomScrollView( + slivers: [ + const SliverToBoxAdapter( + child: SectionHeader( + 'Recent Emotes', + padding: EdgeInsets.symmetric(vertical: 5.0, horizontal: 10.0), + ), + ), + SliverPadding( + padding: const EdgeInsets.all(10.0), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: MediaQuery.of(context).orientation == Orientation.portrait + ? 8 + : context.read().showVideo + ? 6 + : 16, + mainAxisSpacing: 10.0, + crossAxisSpacing: 10.0, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + final emote = assetsStore.recentEmotes[index]; + final validEmotes = [...assetsStore.emoteToObject.values, ...assetsStore.userEmoteToObject.values]; + final matchingEmotes = validEmotes.where((existingEmote) => existingEmote.name == emote.name); + + return GestureDetector( + onTap: matchingEmotes.isNotEmpty + ? () { + textController.text += ' ' + emote.name; + assetsStore.recentEmotes.removeWhere((recentEmote) => recentEmote.name == matchingEmotes.first.name); + assetsStore.recentEmotes.insert(0, matchingEmotes.first); + } + : null, + child: Tooltip( + message: emote.name, + preferBelow: false, + child: CachedNetworkImage( + 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, + ), + ), + ); + }, + childCount: assetsStore.recentEmotes.length, + ), + ), + ), + ], + ); + } +} diff --git a/lib/screens/channel/stores/chat_assets_store.dart b/lib/screens/channel/stores/chat_assets_store.dart index 424d4cb2..59e41d32 100644 --- a/lib/screens/channel/stores/chat_assets_store.dart +++ b/lib/screens/channel/stores/chat_assets_store.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:frosty/api/bttv_api.dart'; import 'package:frosty/api/ffz_api.dart'; import 'package:frosty/api/seventv_api.dart'; @@ -5,6 +7,7 @@ import 'package:frosty/api/twitch_api.dart'; import 'package:frosty/models/badges.dart'; import 'package:frosty/models/emotes.dart'; import 'package:mobx/mobx.dart'; +import 'package:shared_preferences/shared_preferences.dart'; part 'chat_assets_store.g.dart'; @@ -31,6 +34,9 @@ abstract class _ChatAssetsStoreBase with Store { /// The map of badges ids to their object representation. final twitchBadgesToObject = ObservableMap(); + @readonly + var _recentEmotes = ObservableList(); + /// The map of emote words to their image or GIF URL. May be used by anyone in the chat. @readonly var _emoteToObject = {}; @@ -71,6 +77,8 @@ abstract class _ChatAssetsStoreBase with Store { return emote.type == EmoteType.sevenTVChannel || emote.type == EmoteType.sevenTVGlobal; } + late final ReactionDisposer _disposeReaction; + _ChatAssetsStoreBase({ required this.twitchApi, required this.bttvApi, @@ -78,6 +86,20 @@ abstract class _ChatAssetsStoreBase with Store { required this.sevenTVApi, }); + @action + Future init() async { + // 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(); + + _disposeReaction = autorun((_) { + if (_recentEmotes.length > 48) _recentEmotes.removeLast(); + prefs.setStringList('recent_emotes', _recentEmotes.map((emote) => jsonEncode(emote)).toList()); + }); + } + /// Fetches global and channel assets (badges and emotes) and stores them in [_emoteToUrl] @action Future assetsFuture({ @@ -152,4 +174,6 @@ abstract class _ChatAssetsStoreBase with Store { Future.wait(emoteSets.map((setId) => twitchApi.getEmotesSets(setId: setId, headers: headers).catchError(onError))) .then((emotes) => emotes.expand((list) => list).toList()) .then((userEmotes) => _userEmoteToObject = {for (final emote in userEmotes) emote.name: emote}.asObservable()); + + void dispose() => _disposeReaction(); } diff --git a/lib/screens/channel/stores/chat_assets_store.g.dart b/lib/screens/channel/stores/chat_assets_store.g.dart index 6cfce0fb..b2516fb4 100644 --- a/lib/screens/channel/stores/chat_assets_store.g.dart +++ b/lib/screens/channel/stores/chat_assets_store.g.dart @@ -31,6 +31,23 @@ mixin _$ChatAssetsStore on _ChatAssetsStoreBase, Store { name: '_ChatAssetsStoreBase.sevenTVEmotes')) .value; + final _$_recentEmotesAtom = Atom(name: '_ChatAssetsStoreBase._recentEmotes'); + + ObservableList get recentEmotes { + _$_recentEmotesAtom.reportRead(); + return super._recentEmotes; + } + + @override + ObservableList get _recentEmotes => recentEmotes; + + @override + set _recentEmotes(ObservableList value) { + _$_recentEmotesAtom.reportWrite(value, super._recentEmotes, () { + super._recentEmotes = value; + }); + } + final _$_emoteToObjectAtom = Atom(name: '_ChatAssetsStoreBase._emoteToObject'); @@ -152,6 +169,13 @@ mixin _$ChatAssetsStore on _ChatAssetsStoreBase, Store { }); } + final _$initAsyncAction = AsyncAction('_ChatAssetsStoreBase.init'); + + @override + Future init() { + return _$initAsyncAction.run(() => super.init()); + } + final _$_ChatAssetsStoreBaseActionController = ActionController(name: '_ChatAssetsStoreBase'); diff --git a/lib/screens/channel/stores/chat_store.dart b/lib/screens/channel/stores/chat_store.dart index cf743069..7b837814 100644 --- a/lib/screens/channel/stores/chat_store.dart +++ b/lib/screens/channel/stores/chat_store.dart @@ -95,6 +95,8 @@ abstract class _ChatStoreBase with Store { }); }); + assetsStore.init(); + _messages.add(IRCMessage.createNotice(message: 'Connecting to chat...')); reconnect(); @@ -317,5 +319,6 @@ abstract class _ChatStoreBase with Store { disposeEmoteMenuReaction(); textController.dispose(); scrollController.dispose(); + assetsStore.dispose(); } } diff --git a/lib/screens/settings/stores/settings_store.dart b/lib/screens/settings/stores/settings_store.dart index 84e38ca3..3d40975b 100644 --- a/lib/screens/settings/stores/settings_store.dart +++ b/lib/screens/settings/stores/settings_store.dart @@ -1,4 +1,3 @@ -import 'package:flutter/services.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:mobx/mobx.dart';