diff --git a/lib/components/chat_history/decorated_event.dart b/lib/components/chat_history/decorated_event.dart index d149a1e30..8958aba02 100644 --- a/lib/components/chat_history/decorated_event.dart +++ b/lib/components/chat_history/decorated_event.dart @@ -6,16 +6,21 @@ class DecoratedEventWidget extends StatelessWidget { final Widget child; final ImageProvider? avatar; final IconData? icon; + final BoxDecoration decoration; const DecoratedEventWidget._( - {Key? key, required this.child, this.avatar, this.icon}) + {Key? key, + required this.child, + this.avatar, + this.icon, + this.decoration = const BoxDecoration()}) : super(key: key); @override Widget build(BuildContext context) { return Container( - decoration: BoxDecoration( - color: Theme.of(context).highlightColor, + decoration: decoration.copyWith( + color: decoration.color ?? Theme.of(context).highlightColor, border: Border( left: BorderSide( width: 4, @@ -65,8 +70,11 @@ class DecoratedEventWidget extends StatelessWidget { } const DecoratedEventWidget.avatar( - {Key? key, required Widget child, required ImageProvider avatar}) - : this._(key: key, child: child, avatar: avatar); + {Key? key, + required Widget child, + required ImageProvider avatar, + BoxDecoration decoration = const BoxDecoration()}) + : this._(key: key, child: child, avatar: avatar, decoration: decoration); const DecoratedEventWidget.icon( {Key? key, required Widget child, required IconData icon}) diff --git a/lib/components/chat_history/message.dart b/lib/components/chat_history/message.dart index 715a816f2..ce3036261 100644 --- a/lib/components/chat_history/message.dart +++ b/lib/components/chat_history/message.dart @@ -12,6 +12,7 @@ import 'package:rtchat/components/chat_history/twitch/message.dart'; import 'package:rtchat/components/chat_history/twitch/poll_event.dart'; import 'package:rtchat/components/chat_history/twitch/prediction_event.dart'; import 'package:rtchat/components/chat_history/twitch/raid_event.dart'; +import 'package:rtchat/components/chat_history/twitch/raiding_event.dart'; import 'package:rtchat/components/chat_history/twitch/subscription_event.dart'; import 'package:rtchat/models/adapters/actions.dart'; import 'package:rtchat/models/channels.dart'; @@ -23,6 +24,7 @@ import 'package:rtchat/models/messages/twitch/eventsub_configuration.dart'; import 'package:rtchat/models/messages/twitch/hype_train_event.dart'; import 'package:rtchat/models/messages/twitch/message.dart'; import 'package:rtchat/models/messages/twitch/prediction_event.dart'; +import 'package:rtchat/models/messages/twitch/raiding_event.dart'; import 'package:rtchat/models/messages/twitch/subscription_event.dart'; import 'package:rtchat/models/messages/twitch/subscription_gift_event.dart'; import 'package:rtchat/models/messages/twitch/subscription_message_event.dart'; @@ -220,6 +222,12 @@ class ChatHistoryMessage extends StatelessWidget { builder: (_, config, __) => config.showEvent ? TwitchHostEventWidget(m) : Container(), ); + } else if (m is TwitchRaidingEventModel) { + return Selector( + selector: (_, model) => model.raidingEventConfig, + builder: (_, config, __) => + config.showEvent ? TwitchRaidingEventWidget(m) : Container(), + ); } else { throw AssertionError("invalid message type"); } diff --git a/lib/components/chat_history/twitch/prediction_event.dart b/lib/components/chat_history/twitch/prediction_event.dart index 6121803f1..30fe0ee0e 100644 --- a/lib/components/chat_history/twitch/prediction_event.dart +++ b/lib/components/chat_history/twitch/prediction_event.dart @@ -11,7 +11,7 @@ class TwitchPredictionEventWidget extends StatelessWidget { @override Widget build(BuildContext context) { - return model.status != "cancelled" + return model.status != "canceled" ? DecoratedEventWidget( child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/components/chat_history/twitch/raiding_event.dart b/lib/components/chat_history/twitch/raiding_event.dart new file mode 100644 index 000000000..e21acb57e --- /dev/null +++ b/lib/components/chat_history/twitch/raiding_event.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:rtchat/components/chat_history/decorated_event.dart'; +import 'package:rtchat/models/channels.dart'; +import 'package:rtchat/models/messages/twitch/raiding_event.dart'; +import 'package:rtchat/models/user.dart'; + +class TwitchRaidingEventWidget extends StatelessWidget { + final TwitchRaidingEventModel model; + + const TwitchRaidingEventWidget(this.model, {Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + if (!model.isComplete) { + return StreamBuilder( + stream: Stream.periodic(const Duration(milliseconds: 500), (x) => x), + builder: (context, snapshot) { + final flash = snapshot.data == null || snapshot.data! % 2 == 0; + final expiration = model.timestamp.add(model.duration); + final remaining = expiration.difference(DateTime.now()); + return Stack(children: [ + Positioned.fill( + child: AnimatedContainer( + duration: const Duration(milliseconds: 500), + color: flash + ? Theme.of(context).highlightColor + : Theme.of(context).colorScheme.secondary)), + DecoratedEventWidget.avatar( + decoration: const BoxDecoration(color: Colors.transparent), + avatar: NetworkImage(model.targetUser.profilePictureUrl), + child: Row(children: [ + Text.rich(TextSpan( + children: [ + const TextSpan(text: "Raiding "), + TextSpan( + text: model.targetUser.displayName, + style: Theme.of(context).textTheme.subtitle2), + const TextSpan(text: "."), + ], + )), + const Spacer(), + Text.rich(TextSpan( + text: remaining.isNegative + ? "0s" + : "${remaining.inSeconds}s", + style: Theme.of(context).textTheme.subtitle2)) + ])), + ]); + }); + } else if (model.isSuccessful) { + return GestureDetector( + child: DecoratedEventWidget.avatar( + avatar: NetworkImage(model.targetUser.profilePictureUrl), + child: Row(children: [ + Text.rich(TextSpan( + children: [ + const TextSpan(text: "Raided "), + TextSpan( + text: model.targetUser.displayName, + style: Theme.of(context).textTheme.subtitle2), + const TextSpan(text: "."), + ], + )), + const Spacer(), + Text.rich(TextSpan( + text: "Join", + style: Theme.of(context).textTheme.subtitle2?.copyWith( + color: Theme.of(context) + .buttonTheme + .colorScheme + ?.primary))), + ])), + onTap: () { + final userModel = Provider.of(context, listen: false); + userModel.activeChannel = model.targetUser.asChannel; + }); + } else { + return DecoratedEventWidget.avatar( + avatar: NetworkImage(model.targetUser.profilePictureUrl), + child: Text.rich(TextSpan( + children: [ + const TextSpan(text: "Raid to "), + TextSpan( + text: model.targetUser.displayName, + style: Theme.of(context).textTheme.subtitle2), + const TextSpan(text: " canceled."), + ], + ))); + } + } +} diff --git a/lib/main.dart b/lib/main.dart index d518ca8d9..422d098eb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -40,6 +40,7 @@ import 'package:rtchat/screens/settings/events/hypetrain.dart'; import 'package:rtchat/screens/settings/events/poll.dart'; import 'package:rtchat/screens/settings/events/prediction.dart'; import 'package:rtchat/screens/settings/events/raid.dart'; +import 'package:rtchat/screens/settings/events/raiding.dart'; import 'package:rtchat/screens/settings/events/subscription.dart'; import 'package:rtchat/screens/settings/quick_links.dart'; import 'package:rtchat/screens/settings/settings.dart'; @@ -333,6 +334,7 @@ class _AppState extends State { const HypetrainEventScreen(), '/settings/events/prediction': (context) => const PredictionEventScreen(), + '/settings/events/raiding': (context) => const RaidingEventScreen(), }, ), ); diff --git a/lib/models/adapters/messages.dart b/lib/models/adapters/messages.dart index 19d903e09..c5d09bab3 100644 --- a/lib/models/adapters/messages.dart +++ b/lib/models/adapters/messages.dart @@ -10,6 +10,7 @@ import 'package:rtchat/models/messages/twitch/emote.dart'; import 'package:rtchat/models/messages/twitch/event.dart'; import 'package:rtchat/models/messages/twitch/hype_train_event.dart'; import 'package:rtchat/models/messages/twitch/message.dart'; +import 'package:rtchat/models/messages/twitch/raiding_event.dart'; import 'package:rtchat/models/messages/twitch/subscription_event.dart'; import 'package:rtchat/models/messages/twitch/subscription_gift_event.dart'; import 'package:rtchat/models/messages/twitch/subscription_message_event.dart'; @@ -221,6 +222,22 @@ DeltaEvent? _toDeltaEvent( isOnline: data['type'] == "stream.online", timestamp: data['timestamp'].toDate()); return AppendDeltaEvent(model); + case "raid_update_v2": + return AppendDeltaEvent(TwitchRaidingEventModel.fromDocumentData(data)); + case "raid_cancel_v2": + return UpdateDeltaEvent("raiding.${data['raid']['id']}", (message) { + if (message is! TwitchRaidingEventModel) { + return message; + } + return message.withCancel(); + }); + case "raid_go_v2": + return UpdateDeltaEvent("raiding.${data['raid']['id']}", (message) { + if (message is! TwitchRaidingEventModel) { + return message; + } + return message.withSuccessful(); + }); } return null; } diff --git a/lib/models/layout.dart b/lib/models/layout.dart index 9d9321701..a67a9c2b0 100644 --- a/lib/models/layout.dart +++ b/lib/models/layout.dart @@ -63,7 +63,7 @@ class LayoutModel extends ChangeNotifier { double get panelHeight => _panelHeight; double get panelWidth => _panelWidth; - + set panelWidth(double panelWidth) { _panelWidth = panelWidth; notifyListeners(); diff --git a/lib/models/messages/twitch/eventsub_configuration.dart b/lib/models/messages/twitch/eventsub_configuration.dart index b47a04f3a..89ca999a7 100644 --- a/lib/models/messages/twitch/eventsub_configuration.dart +++ b/lib/models/messages/twitch/eventsub_configuration.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:rtchat/models/messages/twitch/raiding_event.dart'; class FollowEventConfig { bool showEvent; @@ -174,6 +175,9 @@ class EventSubConfigurationModel extends ChangeNotifier { HypetrainEventConfig(true, const Duration(seconds: 6)); PredictionEventConfig predictionEventConfig = PredictionEventConfig(true, const Duration(seconds: 6)); + RaidingEventConfig + raidingEventConfig = // 90 seconds for raid + 10 for join prompt. + RaidingEventConfig(true, const Duration(seconds: 100)); // other configs // final HypeTrainEventConfig; @@ -277,6 +281,16 @@ class EventSubConfigurationModel extends ChangeNotifier { notifyListeners(); } + setRaidingEventDuration(Duration duration) { + raidingEventConfig.eventDuration = duration; + notifyListeners(); + } + + setRaidingEventShowable(bool value) { + raidingEventConfig.showEvent = value; + notifyListeners(); + } + EventSubConfigurationModel.fromJson(Map json) { if (json['followEventConfig'] != null) { followEventConfig = FollowEventConfig.fromJson(json['followEventConfig']); @@ -310,6 +324,10 @@ class EventSubConfigurationModel extends ChangeNotifier { predictionEventConfig = PredictionEventConfig.fromJson(json['predictionEventConfig']); } + if (json['raidingEventConfig'] != null) { + raidingEventConfig = + RaidingEventConfig.fromJson(json['raidingEventConfig']); + } } Map toJson() => { @@ -323,5 +341,6 @@ class EventSubConfigurationModel extends ChangeNotifier { "hostEventConfig": hostEventConfig.toJson(), "hypetrainEventConfig": hypetrainEventConfig.toJson(), "predictionEventConfig": predictionEventConfig.toJson(), + "raidingEventConfig": raidingEventConfig.toJson(), }; } diff --git a/lib/models/messages/twitch/raiding_event.dart b/lib/models/messages/twitch/raiding_event.dart new file mode 100644 index 000000000..90cbb6d55 --- /dev/null +++ b/lib/models/messages/twitch/raiding_event.dart @@ -0,0 +1,83 @@ +import 'package:flutter/cupertino.dart'; +import 'package:rtchat/models/messages/message.dart'; +import 'package:rtchat/models/messages/twitch/user.dart'; + +class RaidingEventConfig { + bool showEvent; + Duration eventDuration; + + RaidingEventConfig(this.showEvent, this.eventDuration); + + RaidingEventConfig.fromJson(Map json) + : showEvent = json['showEvent'], + eventDuration = Duration(seconds: json['eventDuration'].toInt()); + + Map toJson() => { + "showEvent": showEvent, + "eventDuration": eventDuration.inSeconds.toInt(), + }; +} + +class TwitchRaidingEventModel extends MessageModel { + // we don't populate viewer count because it's not accurate anyways. + final Duration duration; + final TwitchUserModel targetUser; + final bool isComplete; + final bool isSuccessful; + + const TwitchRaidingEventModel( + {required DateTime timestamp, + required String messageId, + required this.duration, + required this.targetUser, + this.isComplete = false, + this.isSuccessful = false}) + : super(messageId: messageId, timestamp: timestamp); + + static TwitchRaidingEventModel fromDocumentData(Map data) { + return TwitchRaidingEventModel( + timestamp: data['timestamp'].toDate(), + messageId: "raiding.${data['raid']['id']}", + duration: Duration(seconds: data['raid']['force_raid_now_seconds']), + targetUser: TwitchUserModel( + userId: data['raid']['target_id'], + displayName: data['raid']['target_display_name'], + login: data['raid']['target_login'], + ), + ); + } + + TwitchRaidingEventModel withSuccessful() { + return TwitchRaidingEventModel( + timestamp: timestamp, + messageId: messageId, + duration: duration, + targetUser: targetUser, + isComplete: true, + isSuccessful: true, + ); + } + + TwitchRaidingEventModel withCancel() { + return TwitchRaidingEventModel( + timestamp: timestamp, + messageId: messageId, + duration: duration, + targetUser: targetUser, + isComplete: true, + isSuccessful: false, + ); + } + + @override + bool operator ==(Object other) => + other is TwitchRaidingEventModel && + other.duration == duration && + other.targetUser == targetUser && + other.isComplete == isComplete && + other.isSuccessful == isSuccessful; + + @override + int get hashCode => + hashValues(duration, targetUser, isComplete, isSuccessful); +} diff --git a/lib/models/messages/twitch/user.dart b/lib/models/messages/twitch/user.dart index f3809ce3e..9085c200b 100644 --- a/lib/models/messages/twitch/user.dart +++ b/lib/models/messages/twitch/user.dart @@ -1,4 +1,5 @@ import 'package:flutter/painting.dart'; +import 'package:rtchat/models/channels.dart'; const colors = [ Color(0xFFFF0000), @@ -72,6 +73,8 @@ class TwitchUserModel { return "https://us-central1-rtchat-47692.cloudfunctions.net/getProfilePicture?provider=twitch&channelId=$userId"; } + Channel get asChannel => Channel("twitch", userId, displayName ?? login); + TwitchUserModel.fromJson(Map json) : userId = json["userId"], displayName = json["displayName"], diff --git a/lib/screens/settings/events.dart b/lib/screens/settings/events.dart index 24ece015f..e6a293bf5 100644 --- a/lib/screens/settings/events.dart +++ b/lib/screens/settings/events.dart @@ -8,6 +8,7 @@ import 'package:rtchat/components/chat_history/twitch/hype_train_event.dart'; import 'package:rtchat/components/chat_history/twitch/poll_event.dart'; import 'package:rtchat/components/chat_history/twitch/prediction_event.dart'; import 'package:rtchat/components/chat_history/twitch/raid_event.dart'; +import 'package:rtchat/components/chat_history/twitch/raiding_event.dart'; import 'package:rtchat/components/chat_history/twitch/subscription_event.dart'; import 'package:rtchat/components/style_model_theme.dart'; import 'package:rtchat/models/layout.dart'; @@ -16,6 +17,7 @@ import 'package:rtchat/models/messages/twitch/event.dart'; import 'package:rtchat/models/messages/twitch/eventsub_configuration.dart'; import 'package:rtchat/models/messages/twitch/hype_train_event.dart'; import 'package:rtchat/models/messages/twitch/prediction_event.dart'; +import 'package:rtchat/models/messages/twitch/raiding_event.dart'; import 'package:rtchat/models/messages/twitch/subscription_event.dart'; import 'package:rtchat/models/messages/twitch/user.dart'; @@ -258,6 +260,28 @@ class EventsScreen extends StatelessWidget { Navigator.pushNamed( context, '/settings/events/channel-point'); }), + Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 0), + child: StyleModelTheme( + child: TwitchRaidingEventWidget(TwitchRaidingEventModel( + messageId: '', + timestamp: DateTime.now(), + duration: const Duration(seconds: 90), + targetUser: const TwitchUserModel( + userId: '158394109', + login: 'muxfd', + displayName: 'muxfd'), + )))), + ListTile( + title: const Text('Outgoing raid event config'), + subtitle: const Text('Customize your outgoing raid event'), + trailing: Switch.adaptive( + value: eventSubConfig.raidingEventConfig.showEvent, + onChanged: (value) => + eventSubConfig.setRaidingEventShowable(value)), + onTap: () { + Navigator.pushNamed(context, '/settings/events/raiding'); + }), ]); }); }), diff --git a/lib/screens/settings/events/raiding.dart b/lib/screens/settings/events/raiding.dart new file mode 100644 index 000000000..4a9947167 --- /dev/null +++ b/lib/screens/settings/events/raiding.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:rtchat/models/messages/twitch/eventsub_configuration.dart'; + +class RaidingEventScreen extends StatelessWidget { + const RaidingEventScreen({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("Outgoing Raid Configuration"), + ), + body: Consumer( + builder: (context, model, child) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Pin Duration", + style: TextStyle( + color: Theme.of(context).colorScheme.secondary, + fontWeight: FontWeight.bold, + )), + Slider.adaptive( + value: model.raidingEventConfig.eventDuration.inSeconds + .toDouble(), + min: 0, + max: 30, + divisions: 15, + label: + "${model.raidingEventConfig.eventDuration.inSeconds} seconds", + onChanged: (value) { + model.setRaidingEventDuration( + Duration(seconds: value.toInt())); + }, + ), + SwitchListTile.adaptive( + title: const Text('Enable event'), + subtitle: const Text('Show event in chat history'), + value: model.raidingEventConfig.showEvent, + onChanged: (value) { + model.setRaidingEventShowable(value); + }, + ), + ], + ), + ), + ], + ); + })); + } +} diff --git a/lib/screens/settings/tts.dart b/lib/screens/settings/tts.dart index 3f2080016..a2a409fe4 100644 --- a/lib/screens/settings/tts.dart +++ b/lib/screens/settings/tts.dart @@ -52,7 +52,7 @@ class TextToSpeechScreen extends StatelessWidget { onPressed: () { model.say( SystemMessageModel( - text: + text: "muxfd said have you followed muxfd on twitch?"), force: true); }, diff --git a/test/components/chat_history/twitch/prediction_event_test.dart b/test/components/chat_history/twitch/prediction_event_test.dart index 0c4f0a73b..09be9ce64 100644 --- a/test/components/chat_history/twitch/prediction_event_test.dart +++ b/test/components/chat_history/twitch/prediction_event_test.dart @@ -116,13 +116,13 @@ void main() { expect(findProgressIndicator, findsNWidgets(2)); }); - testWidgets('cancelled prediction should be empty', + testWidgets('canceled prediction should be empty', (WidgetTester tester) async { final model = TwitchPredictionEventModel( timestamp: DateTime.now(), messageId: 'prediction1', title: 'Unresolved prediction', - status: 'cancelled', + status: 'canceled', endTime: DateTime.now(), outcomes: [ TwitchPredictionOutcomeModel('outcome1', 1, 'pink', 'Yes'),