Merge pull request #152 from tommyxchow/dev

Merge latest changes from dev into 90
This commit is contained in:
Tommy Chow 2022-07-03 23:24:54 -04:00 • committed by GitHub
commit 9fe06f95bc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 210 additions and 142 deletions

View file

@ -49,7 +49,7 @@ android {
defaultConfig {
applicationId "com.tommychow.frosty"
minSdkVersion 19
minSdkVersion 26
targetSdkVersion flutter.targetSdkVersion
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName

View file

@ -5,7 +5,7 @@ const clientId = String.fromEnvironment('CLIENT_ID');
const secret = String.fromEnvironment('SECRET');
/// The tracesSampleRate for sentry crash reporting.
const sampleRate = 1.0;
const sampleRate = 0.2;
/// BTTV emotes with zero width to allow for overlaying other emotes.
const zeroWidthEmotes = [

View file

@ -264,6 +264,7 @@ class Emote {
final bool zeroWidth;
final String url;
final EmoteType type;
final String? ownerId;
const Emote({
required this.name,
@ -272,6 +273,7 @@ class Emote {
required this.zeroWidth,
required this.url,
required this.type,
this.ownerId,
});
factory Emote.fromTwitch(EmoteTwitch emote, EmoteType type) => Emote(
@ -279,6 +281,7 @@ class Emote {
zeroWidth: false,
url: 'https://static-cdn.jtvnw.net/emoticons/v2/${emote.id}/default/dark/3.0',
type: type,
ownerId: emote.ownerId,
);
factory Emote.fromBTTV(EmoteBTTV emote, EmoteType type) => Emote(

View file

@ -131,6 +131,7 @@ Emote _$EmoteFromJson(Map<String, dynamic> json) => Emote(
zeroWidth: json['zeroWidth'] as bool,
url: json['url'] as String,
type: $enumDecode(_$EmoteTypeEnumMap, json['type']),
ownerId: json['ownerId'] as String?,
);
Map<String, dynamic> _$EmoteToJson(Emote instance) => <String, dynamic>{
@ -140,6 +141,7 @@ Map<String, dynamic> _$EmoteToJson(Emote instance) => <String, dynamic>{
'zeroWidth': instance.zeroWidth,
'url': instance.url,
'type': _$EmoteTypeEnumMap[instance.type],
'ownerId': instance.ownerId,
};
const _$EmoteTypeEnumMap = {

View file

@ -4,6 +4,7 @@ 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_store.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/button.dart';
class EmoteMenu extends StatefulWidget {
@ -41,33 +42,55 @@ class _EmoteMenuState extends State<EmoteMenu> {
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: sections
.mapIndexed(
(index, section) => Padding(
padding: const EdgeInsets.only(right: 5.0),
child: Observer(
builder: (context) => Button(
onPressed: () {
_pageContoller.animateToPage(
index,
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
widget.chatStore.assetsStore.emoteMenuIndex = index;
},
color: index == widget.chatStore.assetsStore.emoteMenuIndex ? Theme.of(context).colorScheme.secondary : Colors.grey,
child: Text(
section,
style: const TextStyle(
letterSpacing: 0.8,
fontWeight: FontWeight.w600,
),
children: [
...sections.mapIndexed(
(index, section) => Padding(
padding: const EdgeInsets.only(right: 5.0),
child: Observer(
builder: (context) => Button(
onPressed: () {
_pageContoller.animateToPage(
index,
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
widget.chatStore.assetsStore.emoteMenuIndex = index;
},
color: index == widget.chatStore.assetsStore.emoteMenuIndex ? Theme.of(context).colorScheme.secondary : Colors.grey,
child: Text(
section,
style: const TextStyle(
letterSpacing: 0.8,
fontWeight: FontWeight.w600,
),
),
),
),
)
.toList(),
),
),
Button(
onPressed: () async {
await widget.chatStore.getAssets();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: AlertMessage(message: 'Emotes refreshed'),
behavior: SnackBarBehavior.floating,
),
);
},
color: Theme.of(context).colorScheme.secondary,
child: const Text(
'REFRESH EMOTES',
style: TextStyle(
letterSpacing: 0.8,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
Expanded(
@ -81,7 +104,7 @@ class _EmoteMenuState extends State<EmoteMenu> {
Observer(
builder: (_) => EmoteMenuPanel(
chatStore: widget.chatStore,
emotes: widget.chatStore.assetsStore.userEmoteToObject.values.toList(),
twitchEmotes: widget.chatStore.assetsStore.userEmoteSectionToEmotes,
),
),
Observer(

View file

@ -6,78 +6,72 @@ import 'package:frosty/widgets/section_header.dart';
class EmoteMenuPanel extends StatelessWidget {
final ChatStore chatStore;
final List<Emote> emotes;
final List<Emote>? emotes;
final Map<String, List<Emote>>? twitchEmotes;
const EmoteMenuPanel({
Key? key,
required this.chatStore,
required this.emotes,
this.emotes,
this.twitchEmotes,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final globalEmotes = emotes
.where((emote) =>
emote.type == EmoteType.twitchGlobal ||
emote.type == EmoteType.bttvGlobal ||
emote.type == EmoteType.ffzGlobal ||
emote.type == EmoteType.sevenTVGlobal)
.toList();
if (emotes != null) {
final globalEmotes =
emotes!.where((emote) => emote.type == EmoteType.bttvGlobal || emote.type == EmoteType.ffzGlobal || emote.type == EmoteType.sevenTVGlobal).toList();
final channelEmotes = emotes
.where((emote) =>
emote.type == EmoteType.twitchChannel ||
emote.type == EmoteType.bttvChannel ||
emote.type == EmoteType.bttvShared ||
emote.type == EmoteType.ffzChannel ||
emote.type == EmoteType.sevenTVChannel)
.toList();
final channelEmotes = emotes!
.where((emote) =>
emote.type == EmoteType.bttvChannel ||
emote.type == EmoteType.bttvShared ||
emote.type == EmoteType.ffzChannel ||
emote.type == EmoteType.sevenTVChannel)
.toList();
final subEmotes = emotes.where((emote) => emote.type == EmoteType.twitchSub).toList();
final miscEmotes = emotes.where((emote) => emote.type == EmoteType.twitchUnlocked).toList();
return CustomScrollView(
slivers: [
if (globalEmotes.isNotEmpty) ...[
const SliverToBoxAdapter(
child: SectionHeader(
'Global Emotes',
padding: EdgeInsets.symmetric(vertical: 5.0, horizontal: 10.0),
return CustomScrollView(
slivers: [
if (globalEmotes.isNotEmpty) ...[
const SliverToBoxAdapter(
child: SectionHeader(
'Global Emotes',
padding: EdgeInsets.symmetric(vertical: 5.0, horizontal: 10.0),
),
),
),
EmoteMenuSection(
chatStore: chatStore,
emotes: globalEmotes,
),
EmoteMenuSection(
chatStore: chatStore,
emotes: globalEmotes,
),
],
if (channelEmotes.isNotEmpty) ...[
const SliverToBoxAdapter(
child: SectionHeader('Channel Emotes'),
),
EmoteMenuSection(
chatStore: chatStore,
emotes: channelEmotes,
),
],
],
if (channelEmotes.isNotEmpty) ...[
const SliverToBoxAdapter(
child: SectionHeader('Channel Emotes'),
),
EmoteMenuSection(
chatStore: chatStore,
emotes: channelEmotes,
),
);
} else {
return CustomScrollView(
slivers: [
for (final entry in twitchEmotes!.entries) ...[
SliverToBoxAdapter(
child: SectionHeader(
entry.key,
padding: entry.key == 'Global Emotes' ? const EdgeInsets.symmetric(vertical: 5.0, horizontal: 10.0) : null,
),
),
EmoteMenuSection(
chatStore: chatStore,
emotes: entry.value,
),
]
],
if (subEmotes.isNotEmpty) ...[
const SliverToBoxAdapter(
child: SectionHeader('Subscribed Emotes'),
),
EmoteMenuSection(
chatStore: chatStore,
emotes: subEmotes,
),
],
if (miscEmotes.isNotEmpty) ...[
const SliverToBoxAdapter(
child: SectionHeader('Unlocked Emotes'),
),
EmoteMenuSection(
chatStore: chatStore,
emotes: miscEmotes,
),
],
],
);
);
}
}
}

View file

@ -43,8 +43,14 @@ abstract class ChatAssetsStoreBase with Store {
/// The emotes that are "owned" and may be used by the current user.
@readonly
// ignore: prefer_final_fields
var _userEmoteToObject = <String, Emote>{};
/// The Twitch emote types mapped to their emotes (i.e., global, specific channels, unlocked).
@readonly
// ignore: prefer_final_fields
var _userEmoteSectionToEmotes = <String, List<Emote>>{};
/// The map of user IDs to their FFZ badges.
@readonly
var _userToFFZBadges = <String, List<Badge>>{};
@ -91,8 +97,8 @@ abstract class ChatAssetsStoreBase with Store {
// 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>();
_recentEmotes =
prefs.getStringList('recent_emotes')?.map((emoteJson) => Emote.fromJson(jsonDecode(emoteJson))).toList().asObservable() ?? ObservableList<Emote>();
_disposeReaction = autorun((_) {
if (_recentEmotes.length > 48) _recentEmotes.removeLast();
@ -153,10 +159,8 @@ abstract class ChatAssetsStoreBase with Store {
required Function onError,
}) =>
Future.wait([
twitchApi
.getBadgesGlobal()
.then((badges) => twitchBadgesToObject.addAll(badges))
.then((_) => twitchApi.getBadgesChannel(id: channelId).then((badges) => twitchBadgesToObject.addAll(badges)).catchError(onError)),
twitchApi.getBadgesGlobal().then((badges) => twitchBadgesToObject.addAll(badges)).catchError(onError),
twitchApi.getBadgesChannel(id: channelId).then((badges) => twitchBadgesToObject.addAll(badges)).catchError(onError),
ffzApi.getBadges().then((badges) => _userToFFZBadges = badges).catchError(onError),
sevenTVApi.getBadges().then((badges) => _userTo7TVBadges = badges).catchError(onError),
bttvApi.getBadges().then((badges) => _userToBTTVBadges = badges).catchError(onError),
@ -167,10 +171,38 @@ abstract class ChatAssetsStoreBase with Store {
required List<String> emoteSets,
required Map<String, String> headers,
required Function onError,
}) =>
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());
}) async {
final userEmotes = await Future.wait(emoteSets.map((setId) => twitchApi.getEmotesSets(setId: setId, headers: headers).catchError(onError)));
for (final emoteSet in userEmotes) {
if (emoteSet.isNotEmpty) {
if (emoteSet.first.type == EmoteType.twitchSub) {
final owner = await twitchApi.getUser(id: emoteSet.first.ownerId, headers: headers);
_userEmoteSectionToEmotes.update(
owner.displayName,
(existingEmoteSet) => [...existingEmoteSet, ...emoteSet],
ifAbsent: () => emoteSet,
);
} else if (emoteSet.first.type == EmoteType.twitchGlobal) {
_userEmoteSectionToEmotes.update(
'Global Emotes',
(existingEmoteSet) => [...existingEmoteSet, ...emoteSet],
ifAbsent: () => emoteSet,
);
} else if (emoteSet.first.type == EmoteType.twitchUnlocked) {
_userEmoteSectionToEmotes.update(
'Unlocked Emotes',
(existingEmoteSet) => [...existingEmoteSet, ...emoteSet],
ifAbsent: () => emoteSet,
);
}
for (final emote in emoteSet) {
_userEmoteToObject[emote.name] = emote;
}
}
}
}
void dispose() => _disposeReaction();
}

View file

@ -85,6 +85,26 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store {
});
}
late final _$_userEmoteSectionToEmotesAtom = Atom(
name: 'ChatAssetsStoreBase._userEmoteSectionToEmotes', context: context);
Map<String, List<Emote>> get userEmoteSectionToEmotes {
_$_userEmoteSectionToEmotesAtom.reportRead();
return super._userEmoteSectionToEmotes;
}
@override
Map<String, List<Emote>> get _userEmoteSectionToEmotes =>
userEmoteSectionToEmotes;
@override
set _userEmoteSectionToEmotes(Map<String, List<Emote>> value) {
_$_userEmoteSectionToEmotesAtom
.reportWrite(value, super._userEmoteSectionToEmotes, () {
super._userEmoteSectionToEmotes = value;
});
}
late final _$_userToFFZBadgesAtom =
Atom(name: 'ChatAssetsStoreBase._userToFFZBadges', context: context);
@ -179,6 +199,18 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store {
return _$initAsyncAction.run(() => super.init());
}
late final _$userEmotesFutureAsyncAction =
AsyncAction('ChatAssetsStoreBase.userEmotesFuture', context: context);
@override
Future<void> userEmotesFuture(
{required List<String> emoteSets,
required Map<String, String> headers,
required Function onError}) {
return _$userEmotesFutureAsyncAction.run(() => super.userEmotesFuture(
emoteSets: emoteSets, headers: headers, onError: onError));
}
late final _$ChatAssetsStoreBaseActionController =
ActionController(name: 'ChatAssetsStoreBase', context: context);
@ -231,21 +263,6 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store {
}
}
@override
Future<void> userEmotesFuture(
{required List<String> emoteSets,
required Map<String, String> headers,
required Function onError}) {
final _$actionInfo = _$ChatAssetsStoreBaseActionController.startAction(
name: 'ChatAssetsStoreBase.userEmotesFuture');
try {
return super.userEmotesFuture(
emoteSets: emoteSets, headers: headers, onError: onError);
} finally {
_$ChatAssetsStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
String toString() {
return '''

View file

@ -231,19 +231,8 @@ abstract class ChatStoreBase with Store {
} else if (message.contains('Welcome, GLHF!')) {
_messages.add(IRCMessage.createNotice(message: "Connected to $displayName${regexEnglish.hasMatch(displayName) ? '' : ' ($channelName)'}'s chat!"));
// Fetch the assets used in chat including badges and emotes.
assetsStore.assetsFuture(
channelId: channelId,
headers: auth.headersTwitch,
onEmoteError: (error) {
debugPrint(error.toString());
return <Emote>[];
},
onBadgeError: (error) {
debugPrint(error.toString());
return <Badge>[];
},
);
getAssets();
// Reset exponential backoff if successfully connected.
_retries = 0;
_backoffTime = 0;
@ -251,6 +240,21 @@ abstract class ChatStoreBase with Store {
}
}
// Fetch the assets used in chat including badges and emotes.
@action
Future<void> getAssets() async => assetsStore.assetsFuture(
channelId: channelId,
headers: auth.headersTwitch,
onEmoteError: (error) {
debugPrint(error.toString());
return <Emote>[];
},
onBadgeError: (error) {
debugPrint(error.toString());
return <Badge>[];
},
);
/// Re-enables [_autoScroll] and jumps to the latest message.
@action
void resumeScroll() {

View file

@ -1,5 +1,3 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/core/auth/auth_store.dart';
@ -42,20 +40,15 @@ class _HomeState extends State<Home> {
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
children: const [
Text(
'Thank you so much for downloading and trying out Frosty! Hopefully it will make your mobile Twitch viewing experience a little more enjoyable.'),
const SizedBox(height: 20.0),
const Text(
'You can see the full changelog on the app store listing or the "Releases" section on the GitHub repo (link on the top right of settings).'),
const SizedBox(height: 20.0),
const Text('You can also find the FAQ on the GitHub repo (link in Settings -> Other).'),
const SizedBox(height: 20.0),
const Text('If you have any issues or feature requests for the app, please open an issue on GitHub.'),
if (Platform.isAndroid) ...[
const SizedBox(height: 20.0),
const Text('NOTE: Due to limitations with the Twitch web player, video streams may not work on Android versions below 7.1.1.'),
]
SizedBox(height: 20.0),
Text('You can see the full changelog on the app store listing or the "Releases" section on the GitHub repo (link on the top right of settings).'),
SizedBox(height: 20.0),
Text('You can also find the FAQ on the GitHub repo (link in Settings -> Other).'),
SizedBox(height: 20.0),
Text('If you have any issues or feature requests for the app, please open an issue on GitHub.'),
],
),
actions: [

View file

@ -2,14 +2,14 @@ import 'package:flutter/material.dart';
class SectionHeader extends StatelessWidget {
final String text;
final EdgeInsets padding;
final EdgeInsets? padding;
final double? fontSize;
final bool showDivider;
const SectionHeader(
this.text, {
Key? key,
this.padding = const EdgeInsets.fromLTRB(10.0, 20.0, 10.0, 5.0),
this.padding,
this.fontSize,
this.showDivider = false,
}) : super(key: key);
@ -17,7 +17,7 @@ class SectionHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: padding,
padding: padding ?? const EdgeInsets.fromLTRB(10.0, 20.0, 10.0, 5.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [