Add recent emotes section to emote menu
This commit is contained in:
parent
1ad52edbef
commit
ec77d0e29a
10 changed files with 256 additions and 52 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
72
lib/screens/channel/chat/emote_menu/recent_emotes_panel.dart
Normal file
72
lib/screens/channel/chat/emote_menu/recent_emotes_panel.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:mobx/mobx.dart';
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue