Merge pull request #164 from tommyxchow/dev

Release v2.1.0
This commit is contained in:
Tommy Chow 2022-07-29 13:27:02 -04:00 • committed by GitHub
commit c430644204
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 888 additions and 469 deletions

View file

@ -19,6 +19,7 @@
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:supportsPictureInPicture="true"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">

View file

@ -13,13 +13,13 @@ PODS:
- Flutter
- path_provider_ios (0.0.1):
- Flutter
- Sentry (7.19.0):
- Sentry/Core (= 7.19.0)
- Sentry/Core (7.19.0)
- Sentry (7.22.0):
- Sentry/Core (= 7.22.0)
- Sentry/Core (7.22.0)
- sentry_flutter (0.0.1):
- Flutter
- FlutterMacOS
- Sentry (~> 7.19.0)
- Sentry (~> 7.22.0)
- shared_preferences_ios (0.0.1):
- Flutter
- sqflite (0.0.2):
@ -27,6 +27,8 @@ PODS:
- FMDB (>= 2.7.5)
- url_launcher_ios (0.0.1):
- Flutter
- wakelock (0.0.1):
- Flutter
- webview_flutter_wkwebview (0.0.1):
- Flutter
@ -41,6 +43,7 @@ DEPENDENCIES:
- shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/ios`)
- sqflite (from `.symlinks/plugins/sqflite/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- wakelock (from `.symlinks/plugins/wakelock/ios`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`)
SPEC REPOS:
@ -69,6 +72,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/sqflite/ios"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
wakelock:
:path: ".symlinks/plugins/wakelock/ios"
webview_flutter_wkwebview:
:path: ".symlinks/plugins/webview_flutter_wkwebview/ios"
@ -80,11 +85,12 @@ SPEC CHECKSUMS:
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
Sentry: d6b16e66a0ad0c39a0b76d9a1194e68e78c37ce6
sentry_flutter: 609b096af497fa64af7c6c94e792d0780aa472a6
Sentry: 30b51086ca9aac23337880152e95538f7e177f7f
sentry_flutter: eaca55af5c951f4be6c4b1daddce1744c37d8476
shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
webview_flutter_wkwebview: b7e70ef1ddded7e69c796c7390ee74180182971f
PODFILE CHECKSUM: aafe91acc616949ddb318b77800a7f51bffa2a4c

View file

@ -233,7 +233,6 @@ class Emote7TV {
final List<String> visibilitySimple;
final String mime;
final int status;
final List<String> tags;
final List<int> width;
final List<int> height;
final List<List<String>> urls;
@ -246,7 +245,6 @@ class Emote7TV {
this.visibilitySimple,
this.mime,
this.status,
this.tags,
this.width,
this.height,
this.urls,

View file

@ -116,7 +116,6 @@ Emote7TV _$Emote7TVFromJson(Map<String, dynamic> json) => Emote7TV(
.toList(),
json['mime'] as String,
json['status'] as int,
(json['tags'] as List<dynamic>).map((e) => e as String).toList(),
(json['width'] as List<dynamic>).map((e) => e as int).toList(),
(json['height'] as List<dynamic>).map((e) => e as int).toList(),
(json['urls'] as List<dynamic>)
@ -140,7 +139,7 @@ Map<String, dynamic> _$EmoteToJson(Emote instance) => <String, dynamic>{
'height': instance.height,
'zeroWidth': instance.zeroWidth,
'url': instance.url,
'type': _$EmoteTypeEnumMap[instance.type],
'type': _$EmoteTypeEnumMap[instance.type]!,
'ownerId': instance.ownerId,
};

View file

@ -35,13 +35,18 @@ class IRCMessage {
this.mention,
});
/// Returns a list of messages where the gievn CLEARCHAT message is applied.
static List<IRCMessage> clearChat({required List<IRCMessage> messages, required IRCMessage ircMessage}) {
/// Applies the given CLEARCHAT message to the provided messages and buffer.
static void clearChat({
required List<IRCMessage> messages,
required List<IRCMessage> bufferedMessages,
required IRCMessage ircMessage,
}) {
// If there is no message, it means that entire chat was cleared.
if (ircMessage.message == null) {
messages.clear();
bufferedMessages.clear();
messages.add(IRCMessage.createNotice(message: 'Chat was cleared by a moderator'));
return messages;
return;
}
final bannedUser = ircMessage.message;
@ -60,11 +65,26 @@ class IRCMessage {
}
});
return messages;
// Search the buffered messages for the banned/timed-out user.
bufferedMessages.asMap().forEach((i, bufferedMessage) {
if (bufferedMessage.user == bannedUser) {
// Mark the message for removal.
bufferedMessages[i].command = Command.clearChat;
// If timed-out, indicate the duration.
if (banDuration != null) {
bufferedMessages[i].tags['ban-duration'] = banDuration;
}
}
});
}
/// Returns a list of messages where the gievn CLEARMSG message is applied.
static List<IRCMessage> clearMessage({required List<IRCMessage> messages, required IRCMessage ircMessage}) {
/// Applies the given CLEARMSG message to the provided messages and buffer.
static void clearMessage({
required List<IRCMessage> messages,
required List<IRCMessage> bufferedMessages,
required IRCMessage ircMessage,
}) {
final targetId = ircMessage.tags['target-msg-id'];
// Search for the message associated with the ID and mark the message for deletion.
@ -75,7 +95,13 @@ class IRCMessage {
}
}
return messages;
// Search for the buffered message associated with the ID and mark the message for deletion.
for (var i = 0; i < bufferedMessages.length; i++) {
if (bufferedMessages[i].tags['id'] == targetId) {
bufferedMessages[i].command = Command.clearMessage;
break;
}
}
}
/// Returns an [InlineSpan] list that corresponds to the badges, username, words, and emotes of the given [IRCMessage].
@ -86,6 +112,7 @@ class IRCMessage {
required double emoteScale,
required bool isLightTheme,
required bool launchExternal,
required void Function()? onLongPressName,
bool showMessage = true,
bool useZeroWidth = false,
bool useReadableColors = false,
@ -263,11 +290,16 @@ class IRCMessage {
// debugPrint('NEW - NAME: ${tags['display-name']!}, HUE: ${hsl.hue}, SATURATION: ${hsl.saturation}, LIGHNTESS: ${hsl.lightness}');
span.add(
TextSpan(
text: tags['display-name']!,
style: TextStyle(
color: color,
fontWeight: FontWeight.bold,
WidgetSpan(
child: InkWell(
onLongPress: onLongPressName,
child: Text(
tags['display-name']!,
style: TextStyle(
color: color,
fontWeight: FontWeight.bold,
),
),
),
),
);
@ -367,9 +399,7 @@ class IRCMessage {
Column(
children: [
Text(
nextWordIsEmoji
? '${words[index]} - Emoji'
: '${emoteStack.last.name} - ${emoteType[emoteStack.last.type.index]}',
nextWordIsEmoji ? '${words[index]} - Emoji' : '${emoteStack.last.name} - ${emoteType[emoteStack.last.type.index]}',
style: const TextStyle(color: Colors.black),
),
Text(

View file

@ -4,6 +4,7 @@ import 'package:frosty/screens/channel/chat/emote_menu/emote_menu.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_bottom_bar.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_message.dart';
import 'package:frosty/screens/channel/stores/chat_store.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/button.dart';
class Chat extends StatelessWidget {
@ -15,75 +16,99 @@ class Chat extends StatelessWidget {
Widget build(BuildContext context) {
return Observer(
builder: (context) {
return Column(
return Stack(
children: [
Expanded(
child: GestureDetector(
onTap: () {
if (chatStore.assetsStore.showEmoteMenu) {
chatStore.assetsStore.showEmoteMenu = false;
} else if (chatStore.textFieldFocusNode.hasFocus) {
chatStore.textFieldFocusNode.unfocus();
}
},
child: Stack(
alignment: AlignmentDirectional.bottomCenter,
children: [
MediaQuery(
data: MediaQuery.of(context).copyWith(textScaleFactor: chatStore.settings.messageScale),
child: DefaultTextStyle(
style: DefaultTextStyle.of(context).style.copyWith(fontSize: chatStore.settings.fontSize),
child: Observer(
builder: (context) {
return ListView.builder(
padding: EdgeInsets.zero,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
itemCount: chatStore.messages.length,
controller: chatStore.scrollController,
itemBuilder: (context, index) => ChatMessage(
ircMessage: chatStore.messages[index],
chatStore: chatStore,
),
);
},
Column(
children: [
Expanded(
child: GestureDetector(
onTap: () {
if (chatStore.assetsStore.showEmoteMenu) {
chatStore.assetsStore.showEmoteMenu = false;
} else if (chatStore.textFieldFocusNode.hasFocus) {
chatStore.textFieldFocusNode.unfocus();
}
},
child: Stack(
alignment: AlignmentDirectional.bottomCenter,
children: [
MediaQuery(
data: MediaQuery.of(context).copyWith(textScaleFactor: chatStore.settings.messageScale),
child: DefaultTextStyle(
style: DefaultTextStyle.of(context).style.copyWith(fontSize: chatStore.settings.fontSize),
child: Observer(
builder: (context) {
return ListView.builder(
reverse: true,
padding: EdgeInsets.zero,
addAutomaticKeepAlives: false,
controller: chatStore.scrollController,
itemCount: chatStore.renderMessages.length,
itemBuilder: (context, index) => ChatMessage(
ircMessage: chatStore.renderMessages.reversed.toList()[index],
chatStore: chatStore,
),
);
},
),
),
),
),
),
Padding(
padding: const EdgeInsets.all(5.0),
child: Observer(
builder: (_) => AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
child: chatStore.autoScroll
? null
: Button(
padding: const EdgeInsets.symmetric(horizontal: 15.0, vertical: 10.0),
onPressed: chatStore.resumeScroll,
icon: const Icon(Icons.arrow_circle_down),
child: const Text('Resume Scroll'),
),
Padding(
padding: const EdgeInsets.all(5.0),
child: Observer(
builder: (_) => AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
child: chatStore.autoScroll
? null
: Button(
padding: const EdgeInsets.symmetric(horizontal: 15.0, vertical: 10.0),
onPressed: chatStore.resumeScroll,
icon: const Icon(Icons.arrow_circle_down),
child: const Text('Resume Scroll'),
),
),
),
),
),
],
),
],
),
),
),
if (chatStore.settings.showBottomBar) ChatBottomBar(chatStore: chatStore),
AnimatedContainer(
curve: Curves.ease,
duration: const Duration(milliseconds: 200),
height: chatStore.assetsStore.showEmoteMenu ? MediaQuery.of(context).size.height / 3 : 0,
child: AnimatedOpacity(
curve: Curves.ease,
opacity: chatStore.assetsStore.showEmoteMenu ? 1 : 0,
duration: const Duration(milliseconds: 200),
child: EmoteMenu(chatStore: chatStore),
),
),
],
),
if (chatStore.settings.showBottomBar) ChatBottomBar(chatStore: chatStore),
AnimatedContainer(
curve: Curves.ease,
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
height: chatStore.assetsStore.showEmoteMenu ? MediaQuery.of(context).size.height / 3 : 0,
child: AnimatedOpacity(
curve: Curves.ease,
opacity: chatStore.assetsStore.showEmoteMenu ? 1 : 0,
duration: const Duration(milliseconds: 200),
child: EmoteMenu(chatStore: chatStore),
),
onEnd: () => chatStore.scrollController.jumpTo(chatStore.scrollController.position.maxScrollExtent),
child: chatStore.notification != null
? Align(
alignment: Alignment.topCenter,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.8),
borderRadius: BorderRadius.circular(30.0),
),
width: double.infinity,
padding: const EdgeInsets.all(10.0),
margin: const EdgeInsets.all(10.0),
child: AlertMessage(
message: chatStore.notification!,
color: Colors.white,
),
),
)
: null,
),
],
);

View file

@ -6,7 +6,7 @@ import 'package:frosty/screens/channel/stores/chat_details_store.dart';
import 'package:frosty/screens/channel/stores/chat_store.dart';
import 'package:frosty/widgets/modal.dart';
class ChatDetails extends StatelessWidget {
class ChatDetails extends StatefulWidget {
final ChatDetailsStore chatDetailsStore;
final ChatStore chatStore;
final String userLogin;
@ -18,6 +18,17 @@ class ChatDetails extends StatelessWidget {
required this.userLogin,
}) : super(key: key);
@override
State<ChatDetails> createState() => _ChatDetailsState();
}
class _ChatDetailsState extends State<ChatDetails> {
@override
void initState() {
super.initState();
widget.chatDetailsStore.updateChatters();
}
@override
Widget build(BuildContext context) {
return FrostyModal(
@ -30,13 +41,13 @@ class ChatDetails extends StatelessWidget {
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 15.0),
child: ChatModes(roomState: chatDetailsStore.roomState),
child: ChatModes(roomState: widget.chatDetailsStore.roomState),
),
Expanded(
child: ChattersList(
chatDetailsStore: chatDetailsStore,
chatStore: chatStore,
userLogin: userLogin,
chatDetailsStore: widget.chatDetailsStore,
chatStore: widget.chatStore,
userLogin: widget.userLogin,
),
),
],

View file

@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:frosty/models/irc.dart';
import 'package:intl/intl.dart';
class ChatModes extends StatelessWidget {
final ROOMSTATE roomState;
@ -11,10 +10,6 @@ class ChatModes extends StatelessWidget {
@override
Widget build(BuildContext context) {
final test = Intl.plural(2, other: 'test');
debugPrint(test);
return Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [

View file

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:frosty/constants/constants.dart';
import 'package:frosty/screens/channel/stores/chat_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/button.dart';
import 'package:frosty/widgets/dialog.dart';
import 'package:frosty/widgets/section_header.dart';
@ -77,43 +78,49 @@ class _RecentEmotesPanelState extends State<RecentEmotesPanel> {
],
),
),
SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: MediaQuery.of(context).orientation == Orientation.portrait
? 8
: context.read<SettingsStore>().showVideo
? 6
: 16,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
final emote = widget.chatStore.assetsStore.recentEmotes[index];
final validEmotes = [...widget.chatStore.assetsStore.emoteToObject.values, ...widget.chatStore.assetsStore.userEmoteToObject.values];
final matchingEmotes = validEmotes.where((existingEmote) => existingEmote.name == emote.name && existingEmote.type == emote.type);
if (widget.chatStore.assetsStore.recentEmotes.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: AlertMessage(message: 'No recent emotes'),
)
else
SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: MediaQuery.of(context).orientation == Orientation.portrait
? 8
: context.read<SettingsStore>().showVideo
? 6
: 16,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
final emote = widget.chatStore.assetsStore.recentEmotes[index];
final validEmotes = [...widget.chatStore.assetsStore.emoteToObject.values, ...widget.chatStore.assetsStore.userEmoteToObject.values];
final matchingEmotes = validEmotes.where((existingEmote) => existingEmote.name == emote.name && existingEmote.type == emote.type);
return InkWell(
onTap: matchingEmotes.isNotEmpty ? () => widget.chatStore.addEmote(emote) : null,
child: Tooltip(
message: emote.name,
preferBelow: false,
child: Padding(
padding: const EdgeInsets.all(10.0),
child: Center(
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,
height: emote.height?.toDouble() ?? defaultEmoteSize,
width: emote.width?.toDouble(),
return InkWell(
onTap: matchingEmotes.isNotEmpty ? () => widget.chatStore.addEmote(emote) : null,
child: Tooltip(
message: emote.name,
preferBelow: false,
child: Padding(
padding: const EdgeInsets.all(10.0),
child: Center(
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,
height: emote.height?.toDouble() ?? defaultEmoteSize,
width: emote.width?.toDouble(),
),
),
),
),
),
);
},
childCount: widget.chatStore.assetsStore.recentEmotes.length,
);
},
childCount: widget.chatStore.assetsStore.recentEmotes.length,
),
),
),
],
);
}

View file

@ -4,7 +4,6 @@ import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/models/irc.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_user_modal.dart';
import 'package:frosty/screens/channel/stores/chat_store.dart';
import 'package:frosty/widgets/alert_message.dart';
class ChatMessage extends StatelessWidget {
final IRCMessage ircMessage;
@ -20,6 +19,36 @@ class ChatMessage extends StatelessWidget {
@override
Widget build(BuildContext context) {
void onLongPressName() {
// Ignore if the message is a recent message in the modal bottom sheet.
if (isModal) return;
// Ignore if long-pressing own username.
if (ircMessage.user == null || ircMessage.user == chatStore.auth.user.details?.login) return;
HapticFeedback.lightImpact();
showModalBottomSheet(
backgroundColor: Colors.transparent,
isScrollControlled: true,
context: context,
builder: (context) => ChatUserModal(
chatStore: chatStore,
username: ircMessage.user!,
userId: ircMessage.tags['user-id']!,
displayName: ircMessage.tags['display-name']!,
),
);
}
void onLongPressMessage() {
HapticFeedback.lightImpact();
Clipboard.setData(ClipboardData(text: ircMessage.message));
chatStore.notification = 'Message copied';
}
return Observer(
builder: (context) {
Color? color;
@ -33,6 +62,7 @@ class ChatMessage extends StatelessWidget {
renderMessage = Text.rich(
TextSpan(
children: ircMessage.generateSpan(
onLongPressName: onLongPressName,
style: DefaultTextStyle.of(context).style,
assetsStore: chatStore.assetsStore,
emoteScale: chatStore.settings.emoteScale,
@ -58,6 +88,7 @@ class ChatMessage extends StatelessWidget {
Text.rich(
TextSpan(
children: ircMessage.generateSpan(
onLongPressName: onLongPressName,
style: DefaultTextStyle.of(context).style,
assetsStore: chatStore.assetsStore,
emoteScale: chatStore.settings.emoteScale,
@ -125,6 +156,7 @@ class ChatMessage extends StatelessWidget {
Text.rich(
TextSpan(
children: ircMessage.generateSpan(
onLongPressName: onLongPressName,
style: DefaultTextStyle.of(context).style,
assetsStore: chatStore.assetsStore,
emoteScale: chatStore.settings.emoteScale,
@ -171,36 +203,7 @@ class ChatMessage extends StatelessWidget {
FocusScope.of(context).unfocus();
if (chatStore.assetsStore.showEmoteMenu) chatStore.assetsStore.showEmoteMenu = false;
},
onLongPress: isModal
? () {
HapticFeedback.lightImpact();
Clipboard.setData(ClipboardData(text: ircMessage.message));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: AlertMessage(message: 'Message copied to clipboard'),
behavior: SnackBarBehavior.floating,
),
);
}
: ircMessage.user != null && ircMessage.user != chatStore.auth.user.details?.login
? () {
HapticFeedback.lightImpact();
showModalBottomSheet(
backgroundColor: Colors.transparent,
isScrollControlled: true,
context: context,
builder: (context) => ChatUserModal(
chatStore: chatStore,
username: ircMessage.user!,
userId: ircMessage.tags['user-id']!,
displayName: ircMessage.tags['display-name']!,
),
);
}
: null,
onLongPress: onLongPressMessage,
child: coloredMessage,
);
},

View file

@ -10,6 +10,7 @@ import 'package:frosty/screens/channel/stores/chat_assets_store.dart';
import 'package:frosty/screens/channel/stores/chat_details_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:mobx/mobx.dart';
import 'package:wakelock/wakelock.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
part 'chat_store.g.dart';
@ -18,8 +19,11 @@ part 'chat_store.g.dart';
class ChatStore = ChatStoreBase with _$ChatStore;
abstract class ChatStoreBase with Store {
static const _messageUpperLimit = 7500;
static const _messageLimit = 5000;
/// The total maximum amount of messages in chat.
static const _messageLimit = 10000;
/// The maximum ammount of messages to render when autoscroll is enabled.
static const _renderMessageLimit = 100;
/// The provided auth store to determine login status, get the token, and use the headers for requests.
final AuthStore auth;
@ -63,9 +67,30 @@ abstract class ChatStoreBase with Store {
/// Requested message to be sent by the user. Will only be sent on receipt of a USERNOTICE command.
IRCMessage? toSend;
/// The list of chat messages to add once autoscroll is resumed.
/// This is used as an optimization to prevent the list from being updated/shifted while the user is scrolling.
final _messageBuffer = <IRCMessage>[];
/// The list of chat messages to render and display.
@readonly
var _messages = ObservableList<IRCMessage>();
final messages = ObservableList<IRCMessage>();
/// The list of reaction disposer functions that will be used later when disposing.
final reactions = <ReactionDisposer>[];
/// The periodic timer used for batching chat message re-renders.
late final Timer _messageBufferTimer;
@computed
List<IRCMessage> get renderMessages {
// If autoscroll is disabled, render ALL messages in chat.
// The second condition is to prevent an out of index error with sublist.
if (!_autoScroll || messages.length < _renderMessageLimit) return messages;
// When autoscroll is enabled, only show the first [_renderMessageLimit] messages.
// This will improve performance by only rendering a limited amount of messages
// instead of the entire history at all times.
return messages.sublist(messages.length - _renderMessageLimit);
}
/// If the chat should automatically scroll/jump to the latest message.
@readonly
@ -87,7 +112,12 @@ abstract class ChatStoreBase with Store {
@observable
var expandChat = false;
final reactions = <ReactionDisposer>[];
/// A notification message to display above the chat.
@observable
String? notification;
/// Timer used for dismissing the notification.
Timer? _notificationTimer;
ChatStoreBase({
required this.auth,
@ -98,20 +128,38 @@ abstract class ChatStoreBase with Store {
required this.channelId,
required this.displayName,
}) {
// Enable wakelock to prevent the chat from sleeping.
if (settings.chatOnlyPreventSleep) Wakelock.enable();
// Create a reaction that will reconnect to chat when logging in or out.
// Closing the channel will trigger a reconnect with the new credentials.
reactions.add(reaction(
(_) => auth.isLoggedIn,
(_) => _channel?.sink.close(1001),
));
reactions.add(
reaction(
(_) => auth.isLoggedIn,
(_) => _channel?.sink.close(1001),
),
);
reactions.add(
reaction(
(_) => notification,
(_) {
if (_notificationTimer != null) _notificationTimer?.cancel();
_notificationTimer = Timer(const Duration(seconds: 2), () => notification = null);
},
),
);
// Create a timer that will add messages from the buffer every 200 milliseconds.
_messageBufferTimer = Timer.periodic(const Duration(milliseconds: 200), (timer) => addMessages());
assetsStore.init();
chatDetailsStore.updateChatters();
_messages.add(IRCMessage.createNotice(message: 'Connecting to chat...'));
_messageBuffer.add(IRCMessage.createNotice(message: 'Connecting to chat...'));
if (settings.chatDelay > 0) {
_messages.add(IRCMessage.createNotice(
_messageBuffer.add(IRCMessage.createNotice(
message: 'Waiting ${settings.chatDelay.toInt()} ${settings.chatDelay == 1.0 ? 'second' : 'seconds'} due to message delay setting...'));
}
@ -119,12 +167,12 @@ abstract class ChatStoreBase with Store {
// 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.
// Else if the user scrolls back to the bottom edge (latest message), auto-scroll will resume.
if (scrollController.position.pixels < scrollController.position.maxScrollExtent) {
if (_autoScroll == true) _autoScroll = false;
} else if (scrollController.position.atEdge || scrollController.position.pixels > scrollController.position.maxScrollExtent) {
if (_autoScroll == false) _autoScroll = true;
// If the scroll position is at the latest message (maximum possible), enable autoscroll.
// Else if the position is before the latest message (not at the edge), disable autoscroll.
if (scrollController.position.pixels <= 0) {
_autoScroll = true;
} else if (scrollController.position.pixels > 0) {
_autoScroll = false;
}
});
@ -161,7 +209,7 @@ abstract class ChatStoreBase with Store {
// The IRC data can contain more than one message separated by CRLF.
// To account for this, split by CRLF, then loop and process each message.
for (final message in data.trimRight().split('\r\n')) {
// debugPrint(message);
// debugPrint('$message\n');
if (message.startsWith('@')) {
final parsedIRCMessage = IRCMessage.fromString(message, userLogin: auth.user.details?.login);
@ -172,25 +220,33 @@ abstract class ChatStoreBase with Store {
switch (parsedIRCMessage.command) {
case Command.privateMessage:
_messages.add(parsedIRCMessage);
break;
case Command.clearChat:
_messages = IRCMessage.clearChat(messages: _messages, ircMessage: parsedIRCMessage).asObservable();
break;
case Command.clearMessage:
_messages = IRCMessage.clearMessage(messages: _messages, ircMessage: parsedIRCMessage).asObservable();
break;
case Command.notice:
case Command.userNotice:
_messages.add(parsedIRCMessage);
_messageBuffer.add(parsedIRCMessage);
break;
case Command.clearChat:
IRCMessage.clearChat(
messages: messages,
bufferedMessages: _messageBuffer,
ircMessage: parsedIRCMessage,
);
break;
case Command.clearMessage:
IRCMessage.clearMessage(
messages: messages,
bufferedMessages: _messageBuffer,
ircMessage: parsedIRCMessage,
);
break;
case Command.roomState:
chatDetailsStore.roomState = chatDetailsStore.roomState.fromIRCMessage(parsedIRCMessage);
continue;
case Command.userState:
_userState = _userState.fromIRCMessage(parsedIRCMessage);
if (toSend != null) {
_messages.add(toSend!);
textController.clear();
_messageBuffer.add(toSend!);
toSend = null;
}
break;
@ -212,24 +268,13 @@ abstract class ChatStoreBase with Store {
continue;
}
if (_autoScroll) {
if (_messages.length >= _messageLimit) _messages.removeAt(0);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollController.hasClients) scrollController.jumpTo(scrollController.position.maxScrollExtent);
});
}
// Remove messages when an upper limit is reached.
// This will prevent an infinite amount of messages when autoscroll is off.
if (_messages.length >= _messageUpperLimit) _messages.removeRange(0, 500);
// Hard upper-limit of 5000 messages to prevent infinite messages being added when scrolling.
// If the message limit is reached, remove the oldest message.
if (_autoScroll && messages.length >= _messageLimit) messages.removeRange(0, 500);
} else if (message == 'PING :tmi.twitch.tv') {
_channel?.sink.add('PONG :tmi.twitch.tv');
return;
} else if (message.contains('Welcome, GLHF!')) {
_messages.add(IRCMessage.createNotice(message: "Connected to $displayName${regexEnglish.hasMatch(displayName) ? '' : ' ($channelName)'}'s chat!"));
_messageBuffer.add(IRCMessage.createNotice(message: "Connected to $displayName${regexEnglish.hasMatch(displayName) ? '' : ' ($channelName)'}'s chat!"));
getAssets();
@ -258,15 +303,15 @@ abstract class ChatStoreBase with Store {
/// Re-enables [_autoScroll] and jumps to the latest message.
@action
void resumeScroll() {
if (_messages.length >= _messageLimit) _messages.removeRange(0, _messages.length - _messageLimit);
_autoScroll = true;
// Jump to the latest message (bottom of the list/chat).
scrollController.jumpTo(scrollController.position.maxScrollExtent);
scrollController.jumpTo(0);
// Schedule a postFrameCallback in the event a new message is added at the same time.
WidgetsBinding.instance.addPostFrameCallback((_) => scrollController.jumpTo(scrollController.position.maxScrollExtent));
// Add a post frame callback in the event a messages is added at the same time.
WidgetsBinding.instance.addPostFrameCallback((_) {
scrollController.jumpTo(0);
});
}
@action
@ -284,7 +329,7 @@ abstract class ChatStoreBase with Store {
if (_backoffTime > 0) {
// Add notice that chat was disconnected and then wait the backoff time before reconnecting.
final notice = 'Disconnected from chat, waiting $_backoffTime ${_backoffTime == 1 ? 'second' : 'seconds'} before reconnecting...';
_messages.add(IRCMessage.createNotice(message: notice));
_messageBuffer.add(IRCMessage.createNotice(message: notice));
}
await Future.delayed(Duration(seconds: _backoffTime));
@ -294,7 +339,7 @@ abstract class ChatStoreBase with Store {
// Increment the retry count and attempt the reconnect.
_retries++;
_messages.add(IRCMessage.createNotice(message: 'Reconnecting to chat (attempt $_retries)...'));
_messageBuffer.add(IRCMessage.createNotice(message: 'Reconnecting to chat (attempt $_retries)...'));
connectToChat();
},
);
@ -321,6 +366,14 @@ abstract class ChatStoreBase with Store {
}
}
@action
void addMessages() {
if (!_autoScroll || _messageBuffer.isEmpty) return;
messages.addAll(_messageBuffer);
_messageBuffer.clear();
}
/// Sends the given string message by the logged-in user and adds it to [_messages].
@action
void sendMessage(String message) {
@ -328,7 +381,7 @@ abstract class ChatStoreBase with Store {
if (message.isEmpty) return;
if (_channel == null || _channel?.closeCode != null) {
_messages.add(IRCMessage.createNotice(message: 'Failed to send message: disconnected from chat.'));
_messageBuffer.add(IRCMessage.createNotice(message: 'Failed to send message: disconnected from chat.'));
} else {
// Send the message to the IRC chat room.
_channel?.sink.add('PRIVMSG #$channelName :$message');
@ -344,18 +397,11 @@ abstract class ChatStoreBase with Store {
final userChatMessage = IRCMessage.fromString(userStateString);
userChatMessage.localEmotes?.addAll(assetsStore.userEmoteToObject);
if (auth.isLoggedIn && auth.user.details != null) userChatMessage.tags['user-id'] = auth.user.details!.id;
toSend = userChatMessage;
}
// Clear the previous input in the TextField.
textController.clear();
}
// Scroll to the latest message after sending.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollController.hasClients) scrollController.jumpTo(scrollController.position.maxScrollExtent);
});
}
/// Adds the given [emote] to the chat textfield.
@ -382,6 +428,8 @@ abstract class ChatStoreBase with Store {
/// Closes and disposes all the channels and controllers used by the store.
void dispose() {
_messageBufferTimer.cancel();
_channel?.sink.close(1001);
_channel = null;
@ -395,5 +443,8 @@ abstract class ChatStoreBase with Store {
assetsStore.dispose();
chatDetailsStore.dispose();
// Disable wakelock so that the sleep timer will function properly.
Wakelock.disable();
}
}

View file

@ -9,23 +9,13 @@ part of 'chat_store.dart';
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers
mixin _$ChatStore on ChatStoreBase, Store {
late final _$_messagesAtom =
Atom(name: 'ChatStoreBase._messages', context: context);
ObservableList<IRCMessage> get messages {
_$_messagesAtom.reportRead();
return super._messages;
}
Computed<List<IRCMessage>>? _$renderMessagesComputed;
@override
ObservableList<IRCMessage> get _messages => messages;
@override
set _messages(ObservableList<IRCMessage> value) {
_$_messagesAtom.reportWrite(value, super._messages, () {
super._messages = value;
});
}
List<IRCMessage> get renderMessages => (_$renderMessagesComputed ??=
Computed<List<IRCMessage>>(() => super.renderMessages,
name: 'ChatStoreBase.renderMessages'))
.value;
late final _$_autoScrollAtom =
Atom(name: 'ChatStoreBase._autoScroll', context: context);
@ -135,6 +125,30 @@ mixin _$ChatStore on ChatStoreBase, Store {
});
}
late final _$notificationAtom =
Atom(name: 'ChatStoreBase.notification', context: context);
@override
String? get notification {
_$notificationAtom.reportRead();
return super.notification;
}
@override
set notification(String? value) {
_$notificationAtom.reportWrite(value, super.notification, () {
super.notification = value;
});
}
late final _$getAssetsAsyncAction =
AsyncAction('ChatStoreBase.getAssets', context: context);
@override
Future<void> getAssets() {
return _$getAssetsAsyncAction.run(() => super.getAssets());
}
late final _$ChatStoreBaseActionController =
ActionController(name: 'ChatStoreBase', context: context);
@ -171,6 +185,17 @@ mixin _$ChatStore on ChatStoreBase, Store {
}
}
@override
void addMessages() {
final _$actionInfo = _$ChatStoreBaseActionController.startAction(
name: 'ChatStoreBase.addMessages');
try {
return super.addMessages();
} finally {
_$ChatStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void sendMessage(String message) {
final _$actionInfo = _$ChatStoreBaseActionController.startAction(
@ -196,7 +221,9 @@ mixin _$ChatStore on ChatStoreBase, Store {
@override
String toString() {
return '''
expandChat: ${expandChat}
expandChat: ${expandChat},
notification: ${notification},
renderMessages: ${renderMessages}
''';
}
}

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:floating/floating.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:frosty/api/twitch_api.dart';
@ -18,45 +19,36 @@ class VideoStore = VideoStoreBase with _$VideoStore;
abstract class VideoStoreBase with Store {
final TwitchApi twitchApi;
/// The userlogin of the current channel.
final String userLogin;
final AuthStore authStore;
final SettingsStore settingsStore;
/// The [Floating] instance used for initiating PiP on Android.
final floating = Floating();
/// The webview controller used for injecting JavaScript to control the webview and video player.
WebViewController? controller;
late Timer _overlayTimer;
/// The current timer for the sleep timer if active.
Timer? sleepTimer;
@observable
var sleepHours = 0;
/// The timer that handles hiding the overlay automatically
late Timer _overlayTimer;
@observable
var sleepMinutes = 0;
@observable
var timeRemaining = const Duration();
@readonly
var _paused = true;
@readonly
var _overlayVisible = true;
@readonly
var _isIPad = false;
@readonly
StreamTwitch? _streamInfo;
@computed
String get videoUrl => settingsStore.showOverlay
? 'https://player.twitch.tv/?channel=$userLogin&controls=false&muted=false&parent=frosty'
: 'https://player.twitch.tv/?channel=$userLogin&muted=false&parent=frosty';
/// Disposes the overlay reactions.
late final ReactionDisposer _disposeOverlayReaction;
/// The JavaScript channels used to communicate play/pause from the webview to Flutter.
late final javascriptChannels = {
JavascriptChannel(
name: 'Pause',
name: 'VideoPause',
onMessageReceived: (message) => _paused = true,
),
JavascriptChannel(
name: 'Play',
name: 'VideoPlaying',
onMessageReceived: (message) {
_paused = false;
controller?.runJavascript('document.getElementsByTagName("video")[0].muted = false;');
@ -65,93 +57,7 @@ abstract class VideoStoreBase with Store {
),
};
final String userLogin;
final AuthStore authStore;
final SettingsStore settingsStore;
VideoStoreBase({
required this.userLogin,
required this.twitchApi,
required this.authStore,
required this.settingsStore,
}) {
_overlayTimer = Timer(const Duration(seconds: 3), () => _overlayVisible = false);
updateStreamInfo();
}
@action
void handlePausePlay() {
try {
if (_paused) {
controller?.runJavascript('document.getElementsByTagName("video")[0].play();');
} else {
controller?.runJavascript('document.getElementsByTagName("video")[0].pause();');
}
_paused = !_paused;
} catch (e) {
debugPrint(e.toString());
}
}
@action
void handleVideoTap() {
_overlayTimer.cancel();
if (_overlayVisible) {
_overlayVisible = false;
} else {
updateStreamInfo();
_overlayVisible = true;
_overlayTimer = Timer(const Duration(seconds: 5), () => _overlayVisible = false);
}
}
@action
Future<void> updateStreamInfo() async {
try {
_streamInfo = await twitchApi.getStream(userLogin: userLogin, headers: authStore.headersTwitch);
} catch (e) {
debugPrint(e.toString());
_overlayTimer.cancel();
_streamInfo = null;
_paused = true;
}
}
@action
void handleExpand() {
settingsStore.expandInfo = !settingsStore.expandInfo;
_overlayTimer.cancel();
_overlayTimer = Timer(const Duration(seconds: 5), () => _overlayVisible = false);
}
@action
Future<void> handleToggleOverlay() async {
if (settingsStore.toggleableOverlay) {
HapticFeedback.mediumImpact();
settingsStore.showOverlay = !settingsStore.showOverlay;
await controller?.loadUrl(videoUrl);
if (settingsStore.showOverlay) {
_overlayVisible = true;
_overlayTimer.cancel();
_overlayTimer = Timer(const Duration(seconds: 3), () => _overlayVisible = false);
}
}
}
void handleRefresh() {
HapticFeedback.lightImpact();
controller?.reload();
}
/// Used for preventing accidental navigation in the webview.
FutureOr<NavigationDecision> handleNavigation(NavigationRequest navigation) {
if (navigation.url.startsWith('https://player.twitch.tv')) {
return NavigationDecision.navigate;
@ -159,11 +65,67 @@ abstract class VideoStoreBase with Store {
return NavigationDecision.prevent;
}
/// The amount of hours the sleep timer is set to.
@observable
var sleepHours = 0;
/// The amount of minutes the sleep timer is set to.
@observable
var sleepMinutes = 0;
/// The time remaining for the sleep timer.
@observable
var timeRemaining = const Duration();
/// If the video is currently paused.
///
/// Does not pause or play the video, only used for rendering state of the overlay.
@readonly
var _paused = true;
/// If the overlay is should be visible.
@readonly
var _overlayVisible = true;
/// If the current device is iPad.
@readonly
var _isIPad = false;
/// The current stream info, used for displaying relevant info on the overlay.
@readonly
StreamTwitch? _streamInfo;
/// The video URL to use for the webview. Controls will be disabled when custom overlay is enabled.
@computed
String get videoUrl => settingsStore.showOverlay
? 'https://player.twitch.tv/?channel=$userLogin&controls=false&muted=false&parent=frosty'
: 'https://player.twitch.tv/?channel=$userLogin&muted=false&parent=frosty';
VideoStoreBase({
required this.userLogin,
required this.twitchApi,
required this.authStore,
required this.settingsStore,
}) {
// Initialize the [_overlayTimer] to hide the overlay automatically after 5 seconds.
_overlayTimer = Timer(const Duration(seconds: 5), () => _overlayVisible = false);
// Initialize a reaction that will reload the webview whenever the overlay is toggled.
_disposeOverlayReaction = reaction(
(_) => settingsStore.showOverlay,
(_) => controller?.loadUrl(videoUrl),
);
updateStreamInfo();
}
/// Initializes the video webview.
@action
Future<void> initVideo() async {
// Add event listeners to notify the JavaScript channels when the video plays and pauses.
try {
controller?.runJavascript('document.getElementsByTagName("video")[0].addEventListener("pause", () => Pause.postMessage("video paused"));');
controller?.runJavascript('document.getElementsByTagName("video")[0].addEventListener("play", () => Play.postMessage("video playing"));');
controller?.runJavascript('document.getElementsByTagName("video")[0].addEventListener("pause", () => VideoPause.postMessage("video paused"));');
controller?.runJavascript('document.getElementsByTagName("video")[0].addEventListener("playing", () => VideoPlaying.postMessage("video playing"));');
} catch (e) {
debugPrint(e.toString());
}
@ -182,16 +144,78 @@ abstract class VideoStoreBase with Store {
}
}
void requestPictureInPicture() {
/// Called whenever the video/overlay is tapped.
@action
void handleVideoTap() {
_overlayTimer.cancel();
if (_overlayVisible) {
_overlayVisible = false;
} else {
updateStreamInfo();
_overlayVisible = true;
_overlayTimer = Timer(const Duration(seconds: 5), () => _overlayVisible = false);
}
}
/// Updates the stream info from the Twitch API.
///
/// If the stream is offline, disables the overlay.
@action
Future<void> updateStreamInfo() async {
try {
controller?.runJavascript('document.getElementsByTagName("video")[0].requestPictureInPicture();');
_streamInfo = await twitchApi.getStream(userLogin: userLogin, headers: authStore.headersTwitch);
} catch (e) {
debugPrint(e.toString());
_overlayTimer.cancel();
_streamInfo = null;
_paused = true;
}
}
/// Handles toggling "minimal mode" on the overlay.
///
/// "Minimal mode" is when only the channel name is visible on the bottom-left of the overlay.
@action
void handleExpand() {
settingsStore.expandInfo = !settingsStore.expandInfo;
_overlayTimer.cancel();
_overlayTimer = Timer(const Duration(seconds: 5), () => _overlayVisible = false);
}
/// Handles the toggle overlay options.
///
/// The toggle overlay option allows switching between the custom and Twitch's overlay by long-pressing the overlay.
@action
void handleToggleOverlay() {
if (settingsStore.toggleableOverlay) {
HapticFeedback.mediumImpact();
settingsStore.showOverlay = !settingsStore.showOverlay;
if (settingsStore.showOverlay) {
_overlayVisible = true;
_overlayTimer.cancel();
_overlayTimer = Timer(const Duration(seconds: 3), () => _overlayVisible = false);
}
}
}
/// Refreshes the stream webview and updates the stream info.
@action
void handleRefresh() {
HapticFeedback.lightImpact();
controller?.reload();
updateStreamInfo();
}
/// Updates the sleep timer with [sleepHours] and [sleepMinutes].
/// Calls [onTimerFinished] when the sleep timer completes.
@action
void updateSleepTimer({required void Function() onTimerFinished}) {
// If hours and minutes are 0, do nothing.
if (sleepHours == 0 && sleepMinutes == 0) return;
@ -224,8 +248,46 @@ abstract class VideoStoreBase with Store {
}
/// Cancels the sleep timer and resets the time remaining.
@action
void cancelSleepTimer() {
sleepTimer?.cancel();
timeRemaining = const Duration();
}
/// Play or pause the video depending on the current state of [_paused].
void handlePausePlay() {
try {
if (_paused) {
controller?.runJavascript('document.getElementsByTagName("video")[0].play();');
} else {
controller?.runJavascript('document.getElementsByTagName("video")[0].pause();');
}
} catch (e) {
debugPrint(e.toString());
}
}
/// Initiate picture in picture if available.
///
/// On iOS, this will utilize the web picture-in-picture API.
/// On Android, this will utilize the native Android PiP API.
void requestPictureInPicture() {
try {
if (Platform.isAndroid) {
floating.enable();
} else if (Platform.isIOS) {
controller?.runJavascript('document.getElementsByTagName("video")[0].requestPictureInPicture();');
}
} catch (e) {
debugPrint(e.toString());
}
}
@action
void dispose() {
controller?.runJavascript('document.getElementsByTagName("video")[0].pause();');
_disposeOverlayReaction();
floating.dispose();
sleepTimer?.cancel();
}
}

View file

@ -137,23 +137,6 @@ mixin _$VideoStore on VideoStoreBase, Store {
});
}
late final _$updateStreamInfoAsyncAction =
AsyncAction('VideoStoreBase.updateStreamInfo', context: context);
@override
Future<void> updateStreamInfo() {
return _$updateStreamInfoAsyncAction.run(() => super.updateStreamInfo());
}
late final _$handleToggleOverlayAsyncAction =
AsyncAction('VideoStoreBase.handleToggleOverlay', context: context);
@override
Future<void> handleToggleOverlay() {
return _$handleToggleOverlayAsyncAction
.run(() => super.handleToggleOverlay());
}
late final _$initVideoAsyncAction =
AsyncAction('VideoStoreBase.initVideo', context: context);
@ -162,20 +145,17 @@ mixin _$VideoStore on VideoStoreBase, Store {
return _$initVideoAsyncAction.run(() => super.initVideo());
}
late final _$VideoStoreBaseActionController =
ActionController(name: 'VideoStoreBase', context: context);
late final _$updateStreamInfoAsyncAction =
AsyncAction('VideoStoreBase.updateStreamInfo', context: context);
@override
void handlePausePlay() {
final _$actionInfo = _$VideoStoreBaseActionController.startAction(
name: 'VideoStoreBase.handlePausePlay');
try {
return super.handlePausePlay();
} finally {
_$VideoStoreBaseActionController.endAction(_$actionInfo);
}
Future<void> updateStreamInfo() {
return _$updateStreamInfoAsyncAction.run(() => super.updateStreamInfo());
}
late final _$VideoStoreBaseActionController =
ActionController(name: 'VideoStoreBase', context: context);
@override
void handleVideoTap() {
final _$actionInfo = _$VideoStoreBaseActionController.startAction(
@ -198,6 +178,61 @@ mixin _$VideoStore on VideoStoreBase, Store {
}
}
@override
void handleToggleOverlay() {
final _$actionInfo = _$VideoStoreBaseActionController.startAction(
name: 'VideoStoreBase.handleToggleOverlay');
try {
return super.handleToggleOverlay();
} finally {
_$VideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void handleRefresh() {
final _$actionInfo = _$VideoStoreBaseActionController.startAction(
name: 'VideoStoreBase.handleRefresh');
try {
return super.handleRefresh();
} finally {
_$VideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void updateSleepTimer({required void Function() onTimerFinished}) {
final _$actionInfo = _$VideoStoreBaseActionController.startAction(
name: 'VideoStoreBase.updateSleepTimer');
try {
return super.updateSleepTimer(onTimerFinished: onTimerFinished);
} finally {
_$VideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void cancelSleepTimer() {
final _$actionInfo = _$VideoStoreBaseActionController.startAction(
name: 'VideoStoreBase.cancelSleepTimer');
try {
return super.cancelSleepTimer();
} finally {
_$VideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void dispose() {
final _$actionInfo = _$VideoStoreBaseActionController.startAction(
name: 'VideoStoreBase.dispose');
try {
return super.dispose();
} finally {
_$VideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
String toString() {
return '''

View file

@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:io';
import 'package:floating/floating.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
@ -88,6 +89,12 @@ class _VideoChatState extends State<VideoChat> {
final overlay = GestureDetector(
onLongPress: _videoStore.handleToggleOverlay,
onDoubleTap: () {
if (MediaQuery.of(context).orientation == Orientation.portrait) return;
// Double tap to toggle fullscreen in landscape mode.
settingsStore.fullScreen = !settingsStore.fullScreen;
},
onTap: () {
if (_chatStore.assetsStore.showEmoteMenu) {
_chatStore.assetsStore.showEmoteMenu = false;
@ -160,14 +167,9 @@ class _VideoChatState extends State<VideoChat> {
],
);
return Scaffold(
final videoChat = Scaffold(
body: OrientationBuilder(
builder: (context, orientation) {
// Scroll to bottom when summoning keyboard or rotating.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_chatStore.scrollController.hasClients) _chatStore.scrollController.jumpTo(_chatStore.scrollController.position.maxScrollExtent);
});
if (orientation == Orientation.landscape) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
@ -199,6 +201,11 @@ class _VideoChatState extends State<VideoChat> {
color: settingsStore.showVideo ? Colors.black : Theme.of(context).scaffoldBackgroundColor,
child: SafeArea(
bottom: false,
left:
(settingsStore.landscapeCutout == LandscapeCutoutType.both || settingsStore.landscapeCutout == LandscapeCutoutType.left) ? false : true,
right: (settingsStore.landscapeCutout == LandscapeCutoutType.both || settingsStore.landscapeCutout == LandscapeCutoutType.right)
? false
: true,
child: settingsStore.showVideo
? settingsStore.fullScreen
? Stack(
@ -246,13 +253,24 @@ class _VideoChatState extends State<VideoChat> {
},
),
);
// If on Android, use PiPSwitcher to enable PiP functionality.
if (Platform.isAndroid) {
return PiPSwitcher(
floating: _videoStore.floating,
childWhenEnabled: player,
childWhenDisabled: videoChat,
);
}
return videoChat;
}
@override
void dispose() {
_chatStore.dispose();
_videoStore.cancelSleepTimer();
_videoStore.dispose();
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
@ -614,7 +632,7 @@ class _VideoOverlay extends StatelessWidget {
),
),
),
if (Platform.isIOS && videoStore.settingsStore.pictureInPicture)
if (videoStore.settingsStore.pictureInPicture)
IconButton(
tooltip: 'Picture-in-picture',
icon: const Icon(

View file

@ -40,6 +40,7 @@ abstract class CategoriesStoreBase with Store {
}
// Fetches the top categories based on the current cursor.
@action
Future<void> getCategories() async {
_isLoading = true;

View file

@ -70,6 +70,14 @@ mixin _$CategoriesStore on CategoriesStoreBase, Store {
});
}
late final _$getCategoriesAsyncAction =
AsyncAction('CategoriesStoreBase.getCategories', context: context);
@override
Future<void> getCategories() {
return _$getCategoriesAsyncAction.run(() => super.getCategories());
}
late final _$CategoriesStoreBaseActionController =
ActionController(name: 'CategoriesStoreBase', context: context);

View file

@ -78,6 +78,7 @@ abstract class ListStoreBase with Store {
}
/// Fetches the streams based on the type and current cursor.
@action
Future<void> getStreams() async {
_isLoading = true;

View file

@ -93,6 +93,14 @@ mixin _$ListStore on ListStoreBase, Store {
});
}
late final _$getStreamsAsyncAction =
AsyncAction('ListStoreBase.getStreams', context: context);
@override
Future<void> getStreams() {
return _$getStreamsAsyncAction.run(() => super.getStreams());
}
late final _$ListStoreBaseActionController =
ActionController(name: 'ListStoreBase', context: context);

View file

@ -21,6 +21,8 @@ class _ChatSettingsState extends State<ChatSettings> {
@override
Widget build(BuildContext context) {
const landscapeCutouts = ['None', 'Left', 'Right', 'Both'];
const timestamps = ['Disabled', '12-hour', '24-hour'];
const sectionPadding = EdgeInsets.only(left: 16.0, bottom: 5.0, top: 30.0);
@ -40,14 +42,6 @@ class _ChatSettingsState extends State<ChatSettings> {
),
),
children: [
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Autocomplete'),
subtitle: const Text('Shows a bar that suggests matching emotes and mentions while typing.'),
value: settingsStore.autocomplete,
onChanged: settingsStore.showBottomBar ? (newValue) => settingsStore.autocomplete = newValue : null,
),
const SizedBox(height: 15.0),
ListTile(
title: Row(
children: [
@ -71,6 +65,20 @@ class _ChatSettingsState extends State<ChatSettings> {
],
),
),
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Prevent sleep in chat-only mode'),
subtitle: const Text('Requires restarting the chat in order to take effect.'),
value: settingsStore.chatOnlyPreventSleep,
onChanged: !settingsStore.showVideo ? (newValue) => settingsStore.chatOnlyPreventSleep = newValue : null,
),
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Autocomplete'),
subtitle: const Text('Shows a bar that suggests matching emotes and mentions while typing.'),
value: settingsStore.autocomplete,
onChanged: settingsStore.showBottomBar ? (newValue) => settingsStore.autocomplete = newValue : null,
),
const SectionHeader(
'Layout',
padding: sectionPadding,
@ -86,6 +94,21 @@ class _ChatSettingsState extends State<ChatSettings> {
value: settingsStore.landscapeChatLeftSide,
onChanged: (newValue) => settingsStore.landscapeChatLeftSide = newValue,
),
ListTile(
isThreeLine: true,
title: const Text('Landscape fill cutout side'),
subtitle: const Text('Overrides and fills the available space in the display cutout/notch.'),
trailing: DropdownButton(
value: settingsStore.landscapeCutout,
onChanged: (LandscapeCutoutType? newValue) => settingsStore.landscapeCutout = newValue!,
items: LandscapeCutoutType.values
.map((LandscapeCutoutType value) => DropdownMenuItem(
value: value,
child: Text(landscapeCutouts[value.index]),
))
.toList(),
),
),
const SizedBox(height: 15.0),
ListTile(
title: Row(

View file

@ -1,5 +1,3 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
@ -34,14 +32,13 @@ class VideoSettings extends StatelessWidget {
value: settingsStore.showOverlay,
onChanged: settingsStore.showVideo ? (newValue) => settingsStore.showOverlay = newValue : null,
),
if (Platform.isIOS)
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Picture-in-picture Button (experimental)'),
subtitle: const Text('Adds a button to enter PiP mode on the bottom right of the overlay (may cause freezes/crashes).'),
value: settingsStore.pictureInPicture,
onChanged: settingsStore.showVideo && settingsStore.showOverlay ? (newValue) => settingsStore.pictureInPicture = newValue : null,
),
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Picture-in-picture Button (experimental)'),
subtitle: const Text('Adds a button to enter PiP mode on the bottom right of the overlay (may cause freezes/crashes).'),
value: settingsStore.pictureInPicture,
onChanged: settingsStore.showVideo && settingsStore.showOverlay ? (newValue) => settingsStore.pictureInPicture = newValue : null,
),
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Long-press player to toggle overlay'),

View file

@ -85,8 +85,10 @@ abstract class _SettingsStoreBase with Store {
}
// * Chat Settings
static const defaultAutocomplete = true;
static const defaultChatDelay = 0.0;
static const defaultChatOnlyPreventSleep = true;
static const defaultAutocomplete = true;
static const defaultLandscapeCutout = LandscapeCutoutType.none;
static const defaultShowBottomBar = true;
static const defaultLandscapeChatLeftSide = false;
@ -106,14 +108,18 @@ abstract class _SettingsStoreBase with Store {
static const defaultMessageSpacing = 10.0;
static const defaultFontSize = 12.0;
@JsonKey(defaultValue: defaultAutocomplete)
@observable
var autocomplete = defaultAutocomplete;
@JsonKey(defaultValue: defaultChatDelay)
@observable
var chatDelay = defaultChatDelay;
@JsonKey(defaultValue: defaultChatOnlyPreventSleep)
@observable
var chatOnlyPreventSleep = defaultChatOnlyPreventSleep;
@JsonKey(defaultValue: defaultAutocomplete)
@observable
var autocomplete = defaultAutocomplete;
@JsonKey(defaultValue: defaultShowBottomBar)
@observable
var showBottomBar = defaultShowBottomBar;
@ -122,6 +128,10 @@ abstract class _SettingsStoreBase with Store {
@observable
var landscapeChatLeftSide = defaultLandscapeChatLeftSide;
@JsonKey(defaultValue: defaultLandscapeCutout)
@observable
var landscapeCutout = defaultLandscapeCutout;
@JsonKey(defaultValue: defaultChatWidth)
@observable
var chatWidth = defaultChatWidth;
@ -172,8 +182,9 @@ abstract class _SettingsStoreBase with Store {
@action
void resetChatSettings() {
autocomplete = defaultAutocomplete;
chatDelay = defaultChatDelay;
chatOnlyPreventSleep = defaultChatOnlyPreventSleep;
autocomplete = defaultAutocomplete;
showBottomBar = defaultShowBottomBar;
landscapeChatLeftSide = defaultLandscapeChatLeftSide;
chatWidth = defaultChatWidth;
@ -228,6 +239,7 @@ abstract class _SettingsStoreBase with Store {
@action
void resetAllSettings() {
resetGeneralSettings();
resetVideoSettings();
resetChatSettings();
resetOtherSettings();
@ -247,3 +259,10 @@ enum TimestampType {
twelve,
twentyFour,
}
enum LandscapeCutoutType {
none,
left,
right,
both,
}

View file

@ -20,10 +20,14 @@ SettingsStore _$SettingsStoreFromJson(Map<String, dynamic> json) =>
..toggleableOverlay = json['toggleableOverlay'] as bool? ?? false
..pictureInPicture = json['pictureInPicture'] as bool? ?? false
..overlayOpacity = (json['overlayOpacity'] as num?)?.toDouble() ?? 0.5
..autocomplete = json['autocomplete'] as bool? ?? true
..chatDelay = (json['chatDelay'] as num?)?.toDouble() ?? 0.0
..chatOnlyPreventSleep = json['chatOnlyPreventSleep'] as bool? ?? true
..autocomplete = json['autocomplete'] as bool? ?? true
..showBottomBar = json['showBottomBar'] as bool? ?? true
..landscapeChatLeftSide = json['landscapeChatLeftSide'] as bool? ?? false
..landscapeCutout = $enumDecodeNullable(
_$LandscapeCutoutTypeEnumMap, json['landscapeCutout']) ??
LandscapeCutoutType.none
..chatWidth = (json['chatWidth'] as num?)?.toDouble() ?? 0.3
..fullScreenChatOverlayOpacity =
(json['fullScreenChatOverlayOpacity'] as num?)?.toDouble() ?? 0.5
@ -48,7 +52,7 @@ SettingsStore _$SettingsStoreFromJson(Map<String, dynamic> json) =>
Map<String, dynamic> _$SettingsStoreToJson(SettingsStore instance) =>
<String, dynamic>{
'themeType': _$ThemeTypeEnumMap[instance.themeType],
'themeType': _$ThemeTypeEnumMap[instance.themeType]!,
'showThumbnails': instance.showThumbnails,
'largeStreamCard': instance.largeStreamCard,
'showThumbnailUptime': instance.showThumbnailUptime,
@ -58,17 +62,20 @@ Map<String, dynamic> _$SettingsStoreToJson(SettingsStore instance) =>
'toggleableOverlay': instance.toggleableOverlay,
'pictureInPicture': instance.pictureInPicture,
'overlayOpacity': instance.overlayOpacity,
'autocomplete': instance.autocomplete,
'chatDelay': instance.chatDelay,
'chatOnlyPreventSleep': instance.chatOnlyPreventSleep,
'autocomplete': instance.autocomplete,
'showBottomBar': instance.showBottomBar,
'landscapeChatLeftSide': instance.landscapeChatLeftSide,
'landscapeCutout':
_$LandscapeCutoutTypeEnumMap[instance.landscapeCutout]!,
'chatWidth': instance.chatWidth,
'fullScreenChatOverlayOpacity': instance.fullScreenChatOverlayOpacity,
'showZeroWidth': instance.showZeroWidth,
'useReadableColors': instance.useReadableColors,
'showDeletedMessages': instance.showDeletedMessages,
'showChatMessageDividers': instance.showChatMessageDividers,
'timestampType': _$TimestampTypeEnumMap[instance.timestampType],
'timestampType': _$TimestampTypeEnumMap[instance.timestampType]!,
'badgeScale': instance.badgeScale,
'emoteScale': instance.emoteScale,
'messageScale': instance.messageScale,
@ -87,6 +94,13 @@ const _$ThemeTypeEnumMap = {
ThemeType.black: 'black',
};
const _$LandscapeCutoutTypeEnumMap = {
LandscapeCutoutType.none: 'none',
LandscapeCutoutType.left: 'left',
LandscapeCutoutType.right: 'right',
LandscapeCutoutType.both: 'both',
};
const _$TimestampTypeEnumMap = {
TimestampType.disabled: 'disabled',
TimestampType.twelve: 'twelve',
@ -260,22 +274,6 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
});
}
late final _$autocompleteAtom =
Atom(name: '_SettingsStoreBase.autocomplete', context: context);
@override
bool get autocomplete {
_$autocompleteAtom.reportRead();
return super.autocomplete;
}
@override
set autocomplete(bool value) {
_$autocompleteAtom.reportWrite(value, super.autocomplete, () {
super.autocomplete = value;
});
}
late final _$chatDelayAtom =
Atom(name: '_SettingsStoreBase.chatDelay', context: context);
@ -292,6 +290,39 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
});
}
late final _$chatOnlyPreventSleepAtom =
Atom(name: '_SettingsStoreBase.chatOnlyPreventSleep', context: context);
@override
bool get chatOnlyPreventSleep {
_$chatOnlyPreventSleepAtom.reportRead();
return super.chatOnlyPreventSleep;
}
@override
set chatOnlyPreventSleep(bool value) {
_$chatOnlyPreventSleepAtom.reportWrite(value, super.chatOnlyPreventSleep,
() {
super.chatOnlyPreventSleep = value;
});
}
late final _$autocompleteAtom =
Atom(name: '_SettingsStoreBase.autocomplete', context: context);
@override
bool get autocomplete {
_$autocompleteAtom.reportRead();
return super.autocomplete;
}
@override
set autocomplete(bool value) {
_$autocompleteAtom.reportWrite(value, super.autocomplete, () {
super.autocomplete = value;
});
}
late final _$showBottomBarAtom =
Atom(name: '_SettingsStoreBase.showBottomBar', context: context);
@ -325,6 +356,22 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
});
}
late final _$landscapeCutoutAtom =
Atom(name: '_SettingsStoreBase.landscapeCutout', context: context);
@override
LandscapeCutoutType get landscapeCutout {
_$landscapeCutoutAtom.reportRead();
return super.landscapeCutout;
}
@override
set landscapeCutout(LandscapeCutoutType value) {
_$landscapeCutoutAtom.reportWrite(value, super.landscapeCutout, () {
super.landscapeCutout = value;
});
}
late final _$chatWidthAtom =
Atom(name: '_SettingsStoreBase.chatWidth', context: context);
@ -667,10 +714,12 @@ showOverlay: ${showOverlay},
toggleableOverlay: ${toggleableOverlay},
pictureInPicture: ${pictureInPicture},
overlayOpacity: ${overlayOpacity},
autocomplete: ${autocomplete},
chatDelay: ${chatDelay},
chatOnlyPreventSleep: ${chatOnlyPreventSleep},
autocomplete: ${autocomplete},
showBottomBar: ${showBottomBar},
landscapeChatLeftSide: ${landscapeChatLeftSide},
landscapeCutout: ${landscapeCutout},
chatWidth: ${chatWidth},
fullScreenChatOverlayOpacity: ${fullScreenChatOverlayOpacity},
showZeroWidth: ${showZeroWidth},

View file

@ -4,11 +4,13 @@ import 'package:flutter/material.dart';
class AlertMessage extends StatelessWidget {
final String message;
final IconData icon;
final Color? color;
const AlertMessage({
Key? key,
required this.message,
final this.icon = Icons.info,
this.color,
}) : super(key: key);
@override
@ -18,15 +20,15 @@ class AlertMessage extends StatelessWidget {
children: [
Icon(
icon,
color: Colors.grey,
color: color ?? Colors.grey,
),
const SizedBox(width: 5.0),
Flexible(
child: Text(
message,
style: const TextStyle(
style: TextStyle(
fontWeight: FontWeight.w500,
color: Colors.grey,
color: color ?? Colors.grey,
),
),
),

View file

@ -40,7 +40,7 @@ class _ButtonState extends State<Button> with SingleTickerProviderStateMixin {
onPrimary: widget.fill ? null : widget.color,
padding: widget.padding,
splashFactory: Platform.isIOS ? NoSplash.splashFactory : null,
textStyle: TextStyle(fontWeight: FontWeight.bold, fontSize: widget.fontSize),
textStyle: TextStyle(fontWeight: FontWeight.w600, fontSize: widget.fontSize),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(30.0)),
elevation: widget.color == null ? 10.0 : 0.0,
).copyWith(

View file

@ -7,21 +7,21 @@ packages:
name: _fe_analyzer_shared
url: "https://pub.dartlang.org"
source: hosted
version: "41.0.0"
version: "43.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
url: "https://pub.dartlang.org"
source: hosted
version: "4.2.0"
version: "4.3.1"
archive:
dependency: transitive
description:
name: archive
url: "https://pub.dartlang.org"
source: hosted
version: "3.3.0"
version: "3.3.1"
args:
dependency: transitive
description:
@ -56,7 +56,7 @@ packages:
name: build_config
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
version: "1.1.0"
build_daemon:
dependency: transitive
description:
@ -77,7 +77,7 @@ packages:
name: build_runner
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.11"
version: "2.2.0"
build_runner_core:
dependency: transitive
description:
@ -98,7 +98,7 @@ packages:
name: built_value
url: "https://pub.dartlang.org"
source: hosted
version: "8.3.3"
version: "8.4.0"
cached_network_image:
dependency: "direct main"
description:
@ -154,7 +154,7 @@ packages:
name: code_builder
url: "https://pub.dartlang.org"
source: hosted
version: "4.1.0"
version: "4.2.0"
collection:
dependency: "direct main"
description:
@ -196,7 +196,7 @@ packages:
name: device_info_plus
url: "https://pub.dartlang.org"
source: hosted
version: "4.0.0"
version: "4.0.1"
device_info_plus_linux:
dependency: transitive
description:
@ -231,7 +231,7 @@ packages:
name: device_info_plus_windows
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.1"
version: "3.0.1"
fake_async:
dependency: transitive
description:
@ -245,7 +245,7 @@ packages:
name: ffi
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.1"
version: "2.0.1"
file:
dependency: transitive
description:
@ -260,6 +260,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.1"
floating:
dependency: "direct main"
description:
name: floating
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0"
flutter:
dependency: "direct main"
description: flutter
@ -306,7 +313,7 @@ packages:
name: flutter_native_splash
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.4"
version: "2.2.6"
flutter_secure_storage:
dependency: "direct main"
description:
@ -449,21 +456,21 @@ packages:
name: json_annotation
url: "https://pub.dartlang.org"
source: hosted
version: "4.5.0"
version: "4.6.0"
json_serializable:
dependency: "direct dev"
description:
name: json_serializable
url: "https://pub.dartlang.org"
source: hosted
version: "6.2.0"
version: "6.3.1"
lint:
dependency: transitive
description:
name: lint
url: "https://pub.dartlang.org"
source: hosted
version: "1.8.2"
version: "1.10.0"
lints:
dependency: transitive
description:
@ -512,7 +519,7 @@ packages:
name: mobx
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.7+4"
version: "2.0.7+5"
mobx_codegen:
dependency: "direct dev"
description:
@ -547,7 +554,7 @@ packages:
name: package_info_plus
url: "https://pub.dartlang.org"
source: hosted
version: "1.4.2"
version: "1.4.3"
package_info_plus_linux:
dependency: transitive
description:
@ -582,7 +589,7 @@ packages:
name: package_info_plus_windows
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.5"
version: "2.0.0"
path:
dependency: transitive
description:
@ -617,14 +624,14 @@ packages:
name: path_provider_android
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.16"
version: "2.0.17"
path_provider_ios:
dependency: transitive
description:
name: path_provider_ios
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.10"
version: "2.0.11"
path_provider_linux:
dependency: transitive
description:
@ -652,7 +659,7 @@ packages:
name: path_provider_windows
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.7"
version: "2.1.0"
pedantic:
dependency: transitive
description:
@ -729,21 +736,21 @@ packages:
name: rxdart
url: "https://pub.dartlang.org"
source: hosted
version: "0.27.4"
version: "0.27.5"
sentry:
dependency: transitive
description:
name: sentry
url: "https://pub.dartlang.org"
source: hosted
version: "6.6.2"
version: "6.9.0"
sentry_flutter:
dependency: "direct main"
description:
name: sentry_flutter
url: "https://pub.dartlang.org"
source: hosted
version: "6.6.2"
version: "6.9.0"
shared_preferences:
dependency: "direct main"
description:
@ -806,7 +813,7 @@ packages:
name: shelf
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.1"
version: "1.3.2"
shelf_web_socket:
dependency: transitive
description:
@ -937,7 +944,7 @@ packages:
name: url_launcher
url: "https://pub.dartlang.org"
source: hosted
version: "6.1.4"
version: "6.1.5"
url_launcher_android:
dependency: transitive
description:
@ -1001,6 +1008,41 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.2"
wakelock:
dependency: "direct main"
description:
name: wakelock
url: "https://pub.dartlang.org"
source: hosted
version: "0.6.2"
wakelock_macos:
dependency: transitive
description:
name: wakelock_macos
url: "https://pub.dartlang.org"
source: hosted
version: "0.4.0"
wakelock_platform_interface:
dependency: transitive
description:
name: wakelock_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.0"
wakelock_web:
dependency: transitive
description:
name: wakelock_web
url: "https://pub.dartlang.org"
source: hosted
version: "0.4.0"
wakelock_windows:
dependency: transitive
description:
name: wakelock_windows
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.0"
watcher:
dependency: transitive
description:
@ -1028,7 +1070,7 @@ packages:
name: webview_flutter_android
url: "https://pub.dartlang.org"
source: hosted
version: "2.8.14"
version: "2.9.2"
webview_flutter_platform_interface:
dependency: transitive
description:
@ -1042,14 +1084,14 @@ packages:
name: webview_flutter_wkwebview
url: "https://pub.dartlang.org"
source: hosted
version: "2.8.1"
version: "2.9.2"
win32:
dependency: transitive
description:
name: win32
url: "https://pub.dartlang.org"
source: hosted
version: "2.6.1"
version: "2.7.0"
xdg_directories:
dependency: transitive
description:

View file

@ -15,7 +15,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 2.0.0+25
version: 2.1.0+26
environment:
sdk: ">=2.15.0 <3.0.0"
@ -29,7 +29,7 @@ environment:
dependencies:
flutter:
sdk: flutter
json_annotation: ^4.5.0
json_annotation: ^4.6.0
# The following adds the Cupertino Icons font to your application.
@ -54,6 +54,8 @@ dependencies:
collection: ^1.16.0
flutter_cache_manager: ^3.3.0
device_info_plus: ^4.0.0
floating: ^1.1.0
wakelock: ^0.6.2
dev_dependencies:
flutter_test:

View file

@ -1,4 +1,5 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/models/emotes.dart';
@ -284,7 +285,6 @@ void main() {
expect(emote.visibilitySimple, ['GLOBAL']);
expect(emote.mime, 'image/gif');
expect(emote.status, 3);
expect(emote.tags, []);
expect(emote.width, [32, 48, 76, 128]);
expect(emote.height, [32, 48, 76, 128]);
@ -383,7 +383,6 @@ void main() {
expect(emote.visibilitySimple, []);
expect(emote.mime, 'image/png');
expect(emote.status, 3);
expect(emote.tags, []);
expect(emote.width, [32, 48, 76, 128]);
expect(emote.height, [32, 48, 76, 128]);