diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index fa84c4c8..02d9c682 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -616,6 +616,16 @@ } } }, + "event": "Event", + "eventMode": "Event Mode", + "eventMode_free": "Free", + "eventMode_restricted": "Restricted", + "eventMode_external": "External", + "eventMode_invite": "Invite", + "eventOnline": "Online Event", + "eventAddEnd": "Add end date", + "eventError_start": "The start date/time is required to be after the current date/time.", + "eventError_end": "The end date/time is required to be before the start date/time.", "action_setLocation": "Set Action Location", "action_hide": "Hide", "action_appBar": "App Bar", diff --git a/lib/src/api/threads.dart b/lib/src/api/threads.dart index 14f78547..b82f8246 100644 --- a/lib/src/api/threads.dart +++ b/lib/src/api/threads.dart @@ -1,14 +1,12 @@ import 'package:collection/collection.dart'; -import 'package:http/http.dart' as http; -import 'package:http_parser/http_parser.dart'; import 'package:image_picker/image_picker.dart'; import 'package:interstellar/src/api/client.dart'; import 'package:interstellar/src/api/feed_source.dart'; import 'package:interstellar/src/controller/server.dart'; +import 'package:interstellar/src/models/event.dart'; import 'package:interstellar/src/models/post.dart'; import 'package:interstellar/src/utils/models.dart'; import 'package:interstellar/src/utils/utils.dart'; -import 'package:mime/mime.dart'; const Map lemmyFeedSortMap = { FeedSort.active: 'Active', @@ -534,6 +532,59 @@ class APIThreads { } } + Future createEvent( + int communityId, { + required String title, + required bool isOc, + required String body, + required String lang, + required bool isAdult, + required DateTime startDate, + required DateTime endDate, + required String timezone, + String? onlineUrl, + JoinMode joinMode = JoinMode.free, + bool online = true, + String feeCurrency = 'USD', + int fee = 0, + }) async { + switch (client.software) { + case ServerSoftware.mbin: + throw UnimplementedError('Polls are unsupported on mbin'); + + case ServerSoftware.lemmy: + throw UnimplementedError('Polls are unsupported on lemmy'); + + case ServerSoftware.piefed: + const path = '/post'; + final response = await client.post( + path, + body: { + 'title': title, + 'community_id': communityId, + 'body': body, + 'nsfw': isAdult, + 'language_id': await client.languageIdFromCode(lang), + 'event': { + 'start': startDate.toUtc().toIso8601String(), + 'end': endDate.toUtc().toIso8601String(), + 'timezone': timezone, + 'online_link': ?onlineUrl, + 'join_mode': joinMode.name, + 'online': online, + 'event_fee_currency': feeCurrency, + 'event_fee_amount': fee, + }, + }, + ); + + return PostModel.fromPiefed( + response.bodyJson, + langCodeIdPairs: await client.languageCodeIdPairs(), + ); + } + } + Future report(int postId, String reason) async { switch (client.software) { case ServerSoftware.mbin: diff --git a/lib/src/models/event.dart b/lib/src/models/event.dart new file mode 100644 index 00000000..5320c80d --- /dev/null +++ b/lib/src/models/event.dart @@ -0,0 +1,64 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:interstellar/src/utils/models.dart'; +import 'package:interstellar/src/utils/utils.dart'; + +part 'event.freezed.dart'; +part 'event.g.dart'; + +@freezed +abstract class LocationModel with _$LocationModel { + const factory LocationModel({ + required String address, + required String city, + required String country, + }) = _LocationModel; + + factory LocationModel.fromJson(JsonMap json) => _$LocationModelFromJson(json); +} + +enum JoinMode { free, restricted, external, invite } + +@freezed +abstract class EventModel with _$EventModel { + const factory EventModel({ + required int postId, + required DateTime start, + required DateTime? end, + required String? timezone, + required int maxAttendees, + required int participantCount, + required bool full, + required Uri? onlineUrl, + required JoinMode joinMode, + required String? externalParticipationUrl, + required bool anonymousParticipation, + required bool online, + required String? buyTicketsUrl, + required String eventFeeCurrency, + required int eventFee, + required LocationModel? location, + }) = _EventModel; + + factory EventModel.fromPiefed(int postId, JsonMap json) => EventModel( + postId: postId, + start: DateTime.parse(json['start']! as String), + end: optionalDateTime(json['end'] as String?), + timezone: json['timezone'] as String?, + maxAttendees: json['max_attendees'] as int? ?? 0, + participantCount: json['participant_count'] as int? ?? 0, + full: json['full'] as bool? ?? false, + onlineUrl: json['online_link'] == null + ? null + : Uri.parse(json['online_link']! as String), + joinMode: JoinMode.values.byName(json['join_mode'] as String? ?? 'free'), + externalParticipationUrl: json['external_participation_url'] as String?, + anonymousParticipation: json['anonymous_participation'] as bool? ?? false, + online: json['online'] as bool? ?? false, + buyTicketsUrl: json['buy_tickets_link'] as String?, + eventFeeCurrency: json['event_fee_currency'] as String? ?? 'USD', + eventFee: json['event_fee_amount'] as int? ?? 0, + location: json['location'] == null + ? null + : LocationModel.fromJson(json['location']! as JsonMap), + ); +} diff --git a/lib/src/models/post.dart b/lib/src/models/post.dart index 9cdfdca1..045a99aa 100644 --- a/lib/src/models/post.dart +++ b/lib/src/models/post.dart @@ -3,6 +3,7 @@ import 'package:interstellar/src/controller/database/database.dart'; import 'package:interstellar/src/models/community.dart'; import 'package:interstellar/src/models/domain.dart'; import 'package:interstellar/src/models/emoji_reaction.dart'; +import 'package:interstellar/src/models/event.dart'; import 'package:interstellar/src/models/image.dart'; import 'package:interstellar/src/models/notification.dart'; import 'package:interstellar/src/models/poll.dart'; @@ -103,6 +104,7 @@ abstract class PostModel with _$PostModel { required List crossPosts, required List flairs, required PollModel? poll, + required EventModel? event, required String? apId, required List? emojiReactions, }) = _PostModel; @@ -154,6 +156,7 @@ abstract class PostModel with _$PostModel { [], flairs: [], poll: null, + event: null, apId: json['apId'] as String?, emojiReactions: null, ); @@ -196,6 +199,7 @@ abstract class PostModel with _$PostModel { crossPosts: [], flairs: [], poll: null, + event: null, apId: json['apId'] as String?, emojiReactions: null, ); @@ -280,6 +284,7 @@ abstract class PostModel with _$PostModel { [], flairs: [], poll: null, + event: null, apId: lemmyPost['ap_id']! as String, emojiReactions: null, ); @@ -383,6 +388,12 @@ abstract class PostModel with _$PostModel { piefedPost['poll']! as Map, ) : null, + event: piefedPost['post_type'] == 'Event' + ? EventModel.fromPiefed( + piefedPost['id']! as int, + piefedPost['event']! as JsonMap, + ) + : null, apId: piefedPost['ap_id']! as String, emojiReactions: (piefedPost['emoji_reactions'] as List?) diff --git a/lib/src/screens/account/messages/message_item.dart b/lib/src/screens/account/messages/message_item.dart index bc126ef3..75cd6066 100644 --- a/lib/src/screens/account/messages/message_item.dart +++ b/lib/src/screens/account/messages/message_item.dart @@ -31,7 +31,9 @@ class MessageItem extends StatelessWidget { overflow: TextOverflow.ellipsis, ), leading: Avatar(messageUser.avatar), - trailing: Text('${dateDiffFormat(item.messages.first.createdAt)} ago'), + trailing: Text( + '${dateDiffFormat(start: item.messages.first.createdAt)} ago', + ), onTap: onClick, ); } diff --git a/lib/src/screens/feed/create_screen.dart b/lib/src/screens/feed/create_screen.dart index 6afd5a5e..352424bc 100644 --- a/lib/src/screens/feed/create_screen.dart +++ b/lib/src/screens/feed/create_screen.dart @@ -7,6 +7,7 @@ import 'package:interstellar/src/controller/database/database.dart'; import 'package:interstellar/src/controller/router.gr.dart'; import 'package:interstellar/src/controller/server.dart'; import 'package:interstellar/src/models/community.dart'; +import 'package:interstellar/src/models/event.dart'; import 'package:interstellar/src/models/post.dart'; import 'package:interstellar/src/screens/explore/community_owner_panel.dart'; import 'package:interstellar/src/utils/ap_urls.dart'; @@ -54,6 +55,7 @@ class _CreateScreenState extends State { final TextEditingController _tagsTextController = TextEditingController(); bool _isOc = false; bool _isAdult = false; + bool _isOnline = true; XFile? _imageFile; String? _altText = ''; String _lang = ''; @@ -63,6 +65,9 @@ class _CreateScreenState extends State { ]; bool _pollModeMultiple = false; Duration _pollDuration = const Duration(days: 3); + DateTime _startDate = DateTime.now(); + DateTime _endDate = DateTime.now(); + JoinMode _eventMode = JoinMode.free; @override void initState() { @@ -306,6 +311,97 @@ class _CreateScreenState extends State { ], ); + Widget dateTimeSelectWidget( + DateTime? date, + void Function(DateTime time) onSelect, { + bool valid = true, + }) => ListTile( + title: DecoratedBox( + decoration: BoxDecoration( + color: valid ? null : Colors.red, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton( + onPressed: () async { + var pickedDate = await showDatePicker( + context: context, + firstDate: DateTime.now(), + lastDate: DateTime.now().add(const Duration(days: 365 * 10)), + ); + if (pickedDate == null) return; + onSelect(pickedDate); + + if (!context.mounted) return; + + final pickedTime = await showTimePicker( + context: context, + initialTime: const TimeOfDay(hour: 0, minute: 0), + ); + if (pickedTime == null) return; + pickedDate = DateTime( + pickedDate.year, + pickedDate.month, + pickedDate.day, + pickedTime.hour, + pickedTime.minute, + ); + onSelect(pickedDate); + }, + child: Text(date != null ? dateOnlyFormat(date) : 'Unset'), + ), + TextButton( + onPressed: () async { + final pickedTime = await showTimePicker( + context: context, + initialTime: const TimeOfDay(hour: 0, minute: 0), + ); + if (pickedTime == null) return; + date ??= DateTime.now(); + date = DateTime( + date!.year, + date!.month, + date!.day, + pickedTime.hour, + pickedTime.minute, + ); + onSelect(date!); + }, + child: Text(date != null ? timeOnlyFormat(date) : 'Unset'), + ), + ], + ), + ), + ); + + Widget eventModeWidget() => ListTile( + title: Text(l(context).eventMode), + onTap: () async { + final mode = await eventMode(context).askSelection(context, _eventMode); + if (mode == null) return; + setState(() { + _eventMode = mode; + }); + }, + trailing: Text(switch (_eventMode) { + JoinMode.free => l(context).eventMode_free, + JoinMode.restricted => l(context).eventMode_restricted, + JoinMode.external => l(context).eventMode_external, + JoinMode.invite => l(context).eventMode_invite, + }), + ); + + Widget eventOnlineWidget() => CheckboxListTile( + title: Text(l(context).eventOnline), + value: _isOnline, + onChanged: (newValue) => setState(() { + _isOnline = newValue!; + }), + controlAffinity: ListTileControlAffinity.leading, + ); + Widget submitButtonWidget(Future Function()? onPressed) => Padding( padding: const EdgeInsets.all(8), child: LoadingFilledButton( @@ -350,7 +446,7 @@ class _CreateScreenState extends State { ServerSoftware.mbin => 5, // Microblog tab only for Mbin ServerSoftware.lemmy => 4, - ServerSoftware.piefed => 5, + ServerSoftware.piefed => 6, // Poll tab only for Piefed }, child: Scaffold( @@ -375,11 +471,16 @@ class _CreateScreenState extends State { text: l(context).create_microblog, icon: const Icon(Symbols.edit_note_rounded), ), - if (ac.serverSoftware == ServerSoftware.piefed) + if (ac.serverSoftware == ServerSoftware.piefed) ...[ Tab( text: l(context).poll, icon: const Icon(Symbols.poll_rounded), ), + Tab( + text: l(context).event, + icon: const Icon(Symbols.event_rounded), + ), + ], Tab( text: l(context).create_community, icon: const Icon(Symbols.group_rounded), @@ -555,7 +656,7 @@ class _CreateScreenState extends State { context.router.pop(); }), ]), - if (ac.serverSoftware == ServerSoftware.piefed) + if (ac.serverSoftware == ServerSoftware.piefed) ...[ listViewWidget([ communityPickerWidget(), titleEditorWidget(), @@ -595,6 +696,81 @@ class _CreateScreenState extends State { }, ), ]), + listViewWidget([ + communityPickerWidget(), + linkEditorWidget(), + titleEditorWidget(), + ?postFlairsWidget(), + bodyEditorWidget(), + Column( + children: [ + dateTimeSelectWidget( + _startDate, + (date) => setState(() { + _startDate = date; + if (_endDate.isBefore(_startDate)) { + _endDate = _startDate; + } + }), + valid: _startDate.isAfter(DateTime.now()), + ), + if (_startDate.isBefore(DateTime.now())) + Text( + l(context).eventError_start, + style: const TextStyle(fontWeight: FontWeight.w200), + ), + dateTimeSelectWidget( + _endDate, + (date) => setState(() { + _endDate = date; + }), + valid: _endDate.isAfter(_startDate), + ), + if (!_endDate.isAfter(_startDate)) + Text( + l(context).eventError_end, + style: const TextStyle(fontWeight: FontWeight.w200), + ), + ], + ), + eventModeWidget(), + eventOnlineWidget(), + nsfwToggleWidget(), + languagePickerWidget(), + submitButtonWidget( + _community == null || + _startDate.isBefore(DateTime.now()) || + _endDate.isBefore(_startDate) || + (_isOnline && _urlTextController.text.isEmpty) + ? null + : () async { + final post = await ac.api.threads.createEvent( + _community!.id, + title: _titleTextController.text, + isOc: _isOc, + body: _bodyTextController.text, + lang: _lang, + isAdult: _isAdult, + startDate: _startDate, + endDate: _endDate, + timezone: DateTime.now().timeZoneName, + joinMode: _eventMode, + online: _isOnline, + onlineUrl: _urlTextController.text.isEmpty + ? null + : _urlTextController.text, + ); + await ac.api.threads.assignFlairs( + post.id, + _postFlairs.map((flair) => flair.id).toList(), + ); + + if (!context.mounted) return; + context.router.pop(); + }, + ), + ]), + ], CommunityOwnerPanelGeneral( data: null, onUpdate: (newCommunity) { @@ -650,3 +826,20 @@ SelectionMenu pollDuration(BuildContext context) => title: l(context).pollDuration_days(365), ), ]); + +SelectionMenu eventMode(BuildContext context) => + SelectionMenu(l(context).event, [ + SelectionMenuItem(value: JoinMode.free, title: l(context).eventMode_free), + SelectionMenuItem( + value: JoinMode.restricted, + title: l(context).eventMode_restricted, + ), + SelectionMenuItem( + value: JoinMode.external, + title: l(context).eventMode_external, + ), + SelectionMenuItem( + value: JoinMode.invite, + title: l(context).eventMode_invite, + ), + ]); diff --git a/lib/src/screens/feed/post_item.dart b/lib/src/screens/feed/post_item.dart index c9ecbcd2..bfaa420f 100644 --- a/lib/src/screens/feed/post_item.dart +++ b/lib/src/screens/feed/post_item.dart @@ -107,6 +107,7 @@ class _PostItemState extends State { createdAt: widget.item.createdAt, editedAt: widget.item.editedAt, poll: widget.item.poll, + event: widget.item.event, isPreview: !(widget.item.type == PostType.microblog) && widget.isPreview, fullImageSize: diff --git a/lib/src/utils/utils.dart b/lib/src/utils/utils.dart index a58fc4fa..c2325d81 100644 --- a/lib/src/utils/utils.dart +++ b/lib/src/utils/utils.dart @@ -37,22 +37,23 @@ String timeOnlyFormat(DateTime input) { return DateFormat.jms().format(input); } -String dateDiffFormat(DateTime input) { - final difference = DateTime.now().difference(input); +String dateDiffFormat({required DateTime start, DateTime? end}) { + end ??= DateTime.now(); + final difference = end.difference(start); - if (difference.inDays > 0) { + if (difference.inDays.abs() > 0) { final years = (difference.inDays / 365).truncate(); - if (years >= 1) { + if (years.abs() >= 1) { return '${years}Y'; } final months = (difference.inDays / 30).truncate(); - if (months >= 1) { + if (months.abs() >= 1) { return '${months}M'; } final weeks = (difference.inDays / 7).truncate(); - if (weeks >= 1) { + if (weeks.abs() >= 1) { return '${weeks}w'; } @@ -61,12 +62,12 @@ String dateDiffFormat(DateTime input) { } final hours = difference.inHours; - if (hours > 0) { + if (hours.abs() > 0) { return '${hours}h'; } final minutes = difference.inMinutes; - if (minutes > 0) { + if (minutes.abs() > 0) { return '${minutes}m'; } diff --git a/lib/src/widgets/content_item/content_info.dart b/lib/src/widgets/content_item/content_info.dart index 194ffe2b..4a434ee5 100644 --- a/lib/src/widgets/content_item/content_info.dart +++ b/lib/src/widgets/content_item/content_info.dart @@ -132,7 +132,7 @@ class ContentInfo extends StatelessWidget { : '\n${l(context).editedAt(dateTimeFormat(editedAt!))}'), triggerMode: TooltipTriggerMode.tap, child: Text( - dateDiffFormat(createdAt!), + dateDiffFormat(start: createdAt!), style: const TextStyle(fontWeight: FontWeight.w300), ), ); diff --git a/lib/src/widgets/content_item/content_item.dart b/lib/src/widgets/content_item/content_item.dart index 66f17079..58bdd850 100644 --- a/lib/src/widgets/content_item/content_item.dart +++ b/lib/src/widgets/content_item/content_item.dart @@ -9,6 +9,7 @@ import 'package:interstellar/src/controller/profile.dart'; import 'package:interstellar/src/controller/router.gr.dart'; import 'package:interstellar/src/models/community.dart'; import 'package:interstellar/src/models/emoji_reaction.dart'; +import 'package:interstellar/src/models/event.dart'; import 'package:interstellar/src/models/image.dart'; import 'package:interstellar/src/models/notification.dart'; import 'package:interstellar/src/models/poll.dart'; @@ -20,6 +21,7 @@ import 'package:interstellar/src/widgets/content_item/action_buttons.dart'; import 'package:interstellar/src/widgets/content_item/content_info.dart'; import 'package:interstellar/src/widgets/content_item/content_item_link_panel.dart'; import 'package:interstellar/src/widgets/content_item/content_reply.dart'; +import 'package:interstellar/src/widgets/content_item/event.dart'; import 'package:interstellar/src/widgets/content_item/poll.dart'; import 'package:interstellar/src/widgets/content_item/swipe_item.dart'; import 'package:interstellar/src/widgets/display_name.dart'; @@ -70,6 +72,7 @@ class ContentItem extends StatefulWidget { this.createdAt, this.editedAt, this.poll, + this.event, this.isPreview = false, this.fullImageSize = false, this.showCommunityFirst = false, @@ -137,6 +140,7 @@ class ContentItem extends StatefulWidget { final DateTime? createdAt; final DateTime? editedAt; final PollModel? poll; + final EventModel? event; final bool isPreview; final bool fullImageSize; @@ -425,12 +429,16 @@ class _ContentItemState extends State { widget.body!.isNotEmpty && !(widget.isPreview && ac.profile.postMode == PostMode.compact)) - ? widget.poll != null + ? widget.poll != null || widget.event != null ? Column( + spacing: 10, crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (widget.event != null) + Event(event: widget.event!), contentBody(context), - Poll(poll: widget.poll!), + if (widget.poll != null) + Poll(poll: widget.poll!), ], ) : contentBody(context) @@ -947,7 +955,7 @@ class _ContentItemState extends State { size: 16, ), const SizedBox(width: 2), - Text(dateDiffFormat(widget.createdAt!)), + Text(dateDiffFormat(start: widget.createdAt!)), ], ), ), diff --git a/lib/src/widgets/content_item/event.dart b/lib/src/widgets/content_item/event.dart new file mode 100644 index 00000000..8dcb2d97 --- /dev/null +++ b/lib/src/widgets/content_item/event.dart @@ -0,0 +1,163 @@ +import 'package:flutter/material.dart'; +import 'package:interstellar/src/controller/controller.dart'; +import 'package:interstellar/src/models/event.dart'; +import 'package:interstellar/src/utils/utils.dart'; +import 'package:interstellar/src/widgets/open_webpage.dart'; +import 'package:provider/provider.dart'; + +class Event extends StatefulWidget { + const Event({required this.event, super.key}); + + final EventModel event; + + @override + State createState() => _EventState(); +} + +class _EventState extends State { + @override + Widget build(BuildContext context) { + final ac = context.read(); + + final hasLocation = + widget.event.location != null && + widget.event.location!.address.isNotEmpty && + widget.event.location!.city.isNotEmpty && + widget.event.location!.country.isNotEmpty; + + return Column( + spacing: 10, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(15), + ), + child: Text(dateOnlyFormat(widget.event.start.toLocal())), + ), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(15), + ), + child: Text(timeOnlyFormat(widget.event.start.toLocal())), + ), + if (widget.event.end != null) + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(15), + ), + child: Text( + 'Duration: ${dateDiffFormat(start: widget.event.start, end: widget.event.end)}', + ), + ), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(15), + ), + child: Text( + 'Join mode: ${switch (widget.event.joinMode) { + JoinMode.free => l(context).eventMode_free, + JoinMode.restricted => l(context).eventMode_restricted, + JoinMode.external => l(context).eventMode_external, + JoinMode.invite => l(context).eventMode_invite, + }}', + ), + ), + if (widget.event.eventFee != 0) + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(15), + ), + child: Text( + 'Fee: ${widget.event.eventFee} ${widget.event.eventFeeCurrency}', + ), + ), + ], + ), + if (hasLocation) + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(15), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Location', + style: Theme.of(context).textTheme.titleMedium, + ), + if (widget.event.location!.address.isNotEmpty) + Text('Address: ${widget.event.location!.address}'), + if (widget.event.location!.city.isNotEmpty) + Text('City: ${widget.event.location!.city}'), + if (widget.event.location!.country.isNotEmpty) + Text('Country: ${widget.event.location!.country}'), + ], + ), + ), + if (widget.event.onlineUrl != null) + Card.outlined( + clipBehavior: Clip.antiAlias, + child: SizedBox( + height: 40, + child: InkWell( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.event.onlineUrl!.host, + style: Theme.of(context).textTheme.bodyMedium!.apply( + decoration: TextDecoration.underline, + ), + softWrap: false, + overflow: TextOverflow.fade, + ), + ], + ), + ), + onTap: () => + openWebpagePrimary(context, widget.event.onlineUrl!), + onLongPress: () => + openWebpageSecondary(context, widget.event.onlineUrl!), + onSecondaryTap: () => + openWebpageSecondary(context, widget.event.onlineUrl!), + ), + ), + ), + ], + ); + } +}