Add recent emotes section to emote menu

This commit is contained in:
Tommy Chow 2022-01-19 23:01:31 -05:00
parent 1ad52edbef
commit ec77d0e29a
10 changed files with 256 additions and 52 deletions

View file

@ -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<String, dynamic> json) => _$EmoteFromJson(json);
Map<String, dynamic> toJson() => _$EmoteToJson(this);
}
enum EmoteType {

View file

@ -123,3 +123,35 @@ Emote7TV _$Emote7TVFromJson(Map<String, dynamic> json) => Emote7TV(
.map((e) => (e as List<dynamic>).map((e) => e as String).toList())
.toList(),
);
Emote _$EmoteFromJson(Map<String, dynamic> 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<String, dynamic> _$EmoteToJson(Emote instance) => <String, dynamic>{
'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',
};

View file

@ -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<EmoteMenu> createState() => _EmoteMenuState();
}
class _EmoteMenuState extends State<EmoteMenu> {
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();
}
}

View file

@ -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<Emote> 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,
),

View file

@ -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<Emote> 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,
),
),

View file

@ -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<SettingsStore>().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,
),
),
),
],
);
}
}

View file

@ -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<String, Badge>();
@readonly
var _recentEmotes = ObservableList<Emote>();
/// The map of emote words to their image or GIF URL. May be used by anyone in the chat.
@readonly
var _emoteToObject = <String, Emote>{};
@ -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<void> 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<Emote>();
_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<void> 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();
}

View file

@ -31,6 +31,23 @@ mixin _$ChatAssetsStore on _ChatAssetsStoreBase, Store {
name: '_ChatAssetsStoreBase.sevenTVEmotes'))
.value;
final _$_recentEmotesAtom = Atom(name: '_ChatAssetsStoreBase._recentEmotes');
ObservableList<Emote> get recentEmotes {
_$_recentEmotesAtom.reportRead();
return super._recentEmotes;
}
@override
ObservableList<Emote> get _recentEmotes => recentEmotes;
@override
set _recentEmotes(ObservableList<Emote> 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<void> init() {
return _$initAsyncAction.run(() => super.init());
}
final _$_ChatAssetsStoreBaseActionController =
ActionController(name: '_ChatAssetsStoreBase');

View file

@ -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();
}
}

View file

@ -1,4 +1,3 @@
import 'package:flutter/services.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:mobx/mobx.dart';