From 577ff8950037c8adb648598725294d78390937e5 Mon Sep 17 00:00:00 2001 From: Jaap Aarts Date: Sun, 9 Aug 2026 15:46:34 +0200 Subject: [PATCH 1/2] Implement countdown timers for even and food openings --- lib/models/payment.dart | 6 +- lib/ui/screens/event_screen.dart | 307 +++++++++++++------------ lib/ui/widgets/timed_state_enable.dart | 138 +++++++++++ 3 files changed, 303 insertions(+), 148 deletions(-) create mode 100644 lib/ui/widgets/timed_state_enable.dart diff --git a/lib/models/payment.dart b/lib/models/payment.dart index f49aa53aa..f3e466d7d 100644 --- a/lib/models/payment.dart +++ b/lib/models/payment.dart @@ -16,13 +16,13 @@ enum PaymentType { String toString() { switch (this) { case PaymentType.cashPayment: - return 'Cash payment'; + return 'Cash'; case PaymentType.cardPayment: - return 'Card payment'; + return 'Card'; case PaymentType.tpayPayment: return 'Thalia Pay'; case PaymentType.wirePayment: - return 'Wire payment'; + return 'Wire tranasfer'; } } } diff --git a/lib/ui/screens/event_screen.dart b/lib/ui/screens/event_screen.dart index 46b2621c2..fed55db9f 100644 --- a/lib/ui/screens/event_screen.dart +++ b/lib/ui/screens/event_screen.dart @@ -14,6 +14,7 @@ import 'package:reaxit/routes.dart'; import 'package:reaxit/ui/widgets.dart'; import 'package:reaxit/ui/widgets/dialog.dart'; import 'package:reaxit/ui/widgets/file_button.dart'; +import 'package:reaxit/ui/widgets/timed_state_enable.dart'; import 'package:share_plus/share_plus.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:reaxit/config.dart'; @@ -37,8 +38,6 @@ class _EventScreenState extends State { late final EventCubit _eventCubit; - final WidgetStatesController _buttonControler = WidgetStatesController(); - @override void initState() { final api = RepositoryProvider.of(context); @@ -102,6 +101,19 @@ class _EventScreenState extends State { /// Create all info of an event until the description, including buttons. Widget _makeEventInfo(Event event) { + List? shifts = event.shiftSet; + Iterable? selforderShifts; + if (shifts != null) { + selforderShifts = shifts.map( + (shift) => TimedEnableButton( + open: shift.start, + close: shift.end, + builder: (context, controler, nextChange) => + _makeFoodShiftButton(shift, controler, nextChange), + ), + ); + } + return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: Column( @@ -109,15 +121,21 @@ class _EventScreenState extends State { children: [ _makeBasicEventInfo(event), if (event.registrationIsRequired) - _makeRequiredRegistrationInfo(event) + TimedEnableButton( + open: event.registrationStart, + close: event.registrationEnd, + onClose: + _eventCubit.load, // To be sure, testing without wasn't done + builder: (context, controler, nextChange) => + _makeRequiredRegistrationInfo(event, controler, nextChange), + ) else if (event.registrationIsOptional) _makeOptionalRegistrationInfo(event) else _makeNoRegistrationInfo(event), if (event.hasFoodEvent) _makeFoodButton(event), - if (event.shiftSet != null) - ...event.shiftSet!.map((shift) => _makeFoodShiftButton(shift)), + ...?selforderShifts, ], ), ); @@ -276,7 +294,11 @@ class _EventScreenState extends State { } // Create the info for events with required registration. - Widget _makeRequiredRegistrationInfo(Event event) { + Widget _makeRequiredRegistrationInfo( + Event event, + WidgetStatesController controler, + DateTime? nextChange, + ) { assert(event.registrationIsRequired); final textTheme = Theme.of(context).textTheme; final dataStyle = textTheme.bodyMedium!.apply(fontSizeDelta: -1); @@ -288,24 +310,21 @@ class _EventScreenState extends State { final textSpans = []; final registrationStatusText = []; Widget registrationButton = const SizedBox.shrink(); - Widget updateButton = const SizedBox.shrink(); - if (event.canCreateRegistration || event.createRegistrationWhenOpen) { - if (event.registrationStart!.isAfter(DateTime.now())) { - _buttonControler.update(WidgetState.disabled, true); - Future.delayed(event.registrationStart!.difference(DateTime.now()), () { - if (mounted) { - _buttonControler.update(WidgetState.disabled, false); - setState(() {}); - } - }); - } - if (event.reachedMaxParticipants) { - registrationButton = _makeJoinQueueButton(event); - } else { - registrationButton = _makeCreateRegistrationButton(event); - } + if ((event.canCreateRegistration || event.createRegistrationWhenOpen) && + !event.registrationClosed()) { + // You can register, or will be as soon as it opens + registrationButton = TimedIconButton( + controller: controler, + onPressed: () => + event.reachedMaxParticipants ? joinQueue(event) : register(event), + opens: event.registrationStarted() ? null : nextChange, + icon: const Icon(Icons.create_outlined), + labelText: event.reachedMaxParticipants ? 'JOIN QUEUE' : 'REGISTER', + countdownPrefix: 'OPENS IN', + ); } else if (event.canCancelRegistration) { + // You can cancel (on time or to late) if (event.cancelDeadlinePassed() && event.registration!.isInvited) { // Cancel too late message, cancel button with fine warning. final text = @@ -320,56 +339,66 @@ class _EventScreenState extends State { } } - if (event.canUpdateRegistration) { - updateButton = _makeUpdateButton(event); - } - - //Registration Status from API - registrationStatusText.add(TextSpan(text: event.registrationStatus)); + // Cancelled _> should get not able to register message: + //Your registration for this event is cancelled. Note that you cannot re-register. - if (event.canCreateRegistration && !event.isRegistered) { + if (event.registrationClosed() && !event.canCancelRegistration) { + registrationStatusText.add( + TextSpan(text: 'Registration is not possible anymore.'), + ); + } else if ((event.canCreateRegistration || + event.createRegistrationWhenOpen) && + !event.isRegistered) { if (!event.registrationStarted()) { // Registration will open .... final registrationStart = dateTimeFormatter.format( event.registrationStart!.toLocal(), ); - // API - textSpans.add( - TextSpan(text: 'Registration will open $registrationStart. '), + registrationStatusText.add( + TextSpan(text: 'Registration will open $registrationStart.'), ); } else if (event.registrationIsOpen()) { - // Terms and conditions, register button. textSpans.add(_makeTermsAndConditions(event)); + if (event.registration != null) { + registrationStatusText.add( + TextSpan( + text: + 'Your registration for this event is cancelled. You may still re-register.', + ), + ); + } else { + registrationStatusText.add(TextSpan(text: 'You can register now.')); + } } - final registration = event.registration; - - if (registration != null) { - if (event.paymentIsRequired) { - if (registration.isPaid) { - if (registration.payment!.type == PaymentType.tpayPayment) { - // You are paying with Thalia Pay. - textSpans.add( - const TextSpan(text: 'You are paying with Thalia Pay. '), - ); - } else { - // You have paid. - textSpans.add(const TextSpan(text: 'You have paid. ')); - } - } else { - // You have not paid yet. - textSpans.add(const TextSpan(text: 'You have not paid yet. ')); - } + } else if (event.isRegistered) { + final registration = event.registration!; + registrationStatusText.add( + TextSpan(text: 'You are registered for thie event.'), + ); + + if (event.paymentIsRequired) { + Payment? payment = registration.payment; + if (payment != null) { + textSpans.add( + TextSpan(text: 'You are paying with ${payment.type.toString()}. '), + ); + } else { + // You have not paid yet. + textSpans.add(const TextSpan(text: 'You have not paid yet. ')); } - if (event.hasEnded()) { - if (registration.present ?? true) { - // You were present. - textSpans.add(const TextSpan(text: 'You were present. ')); - } else { - // You were not present. - textSpans.add(const TextSpan(text: 'You were not present. ')); - } + } + if (event.hasEnded()) { + if (registration.present ?? true) { + textSpans.add(const TextSpan(text: 'You were present. ')); + } else { + textSpans.add(const TextSpan(text: 'You were not present. ')); } } + } else { + // We should avoid using the registration status as much as possible. + // The status is from when we fetched the event, and may not be up-to-date + // when we build. For example, the event may have opened. + registrationStatusText.add(TextSpan(text: event.registrationStatus)); } late Widget paymentButton; @@ -482,7 +511,7 @@ class _EventScreenState extends State { ), const SizedBox(height: 4), registrationButton, - updateButton, + if (event.canUpdateRegistration) _makeUpdateButton(event), paymentButton, ], ); @@ -490,6 +519,8 @@ class _EventScreenState extends State { // Create the info for events with optional registration. Widget _makeOptionalRegistrationInfo(Event event) { + // TODO: Add timers and countdowns for this too + assert(event.registrationIsOptional); final textTheme = Theme.of(context).textTheme; final dataStyle = textTheme.bodyMedium!.apply(fontSizeDelta: -1); @@ -620,90 +651,70 @@ class _EventScreenState extends State { ); } - Widget _makeCreateRegistrationButton(Event event) { - return ElevatedButton.icon( - statesController: _buttonControler, - onPressed: !_buttonControler.value.contains(WidgetState.disabled) - ? () async { - final messenger = ScaffoldMessenger.of(context); - final calendarCubit = BlocProvider.of(context); - final router = GoRouter.of(context); - var confirmed = !event.cancelDeadlinePassed(); - if (!confirmed) { - confirmed = await showConfirmationDialog( - context, - 'Register', - 'Are you sure you want to register? The ' - 'cancellation deadline has already passed.', - ); - } + void register(Event event) async { + final messenger = ScaffoldMessenger.of(context); + final calendarCubit = BlocProvider.of(context); + final router = GoRouter.of(context); + var confirmed = !event.cancelDeadlinePassed(); + if (!confirmed) { + confirmed = await showConfirmationDialog( + context, + 'Register', + 'Are you sure you want to register? The ' + 'cancellation deadline has already passed.', + ); + } - if (confirmed) { - try { - final registration = await _eventCubit.register(); - if (event.hasFields) { - router.pushNamed( - 'event-registration', - pathParameters: { - 'eventPk': event.pk.toString(), - 'registrationPk': registration.pk.toString(), - }, - ); - } - calendarCubit.load(); - } on ApiException { - messenger.showSnackBar( - const SnackBar( - behavior: SnackBarBehavior.floating, - content: Text('Could not register for the event.'), - ), - ); - } - await _eventCubit.load(); - } - } - : null, - icon: const Icon(Icons.create_outlined), - label: const Text('REGISTER'), - ); + if (confirmed) { + try { + final registration = await _eventCubit.register(); + if (event.hasFields) { + router.pushNamed( + 'event-registration', + pathParameters: { + 'eventPk': event.pk.toString(), + 'registrationPk': registration.pk.toString(), + }, + ); + } + calendarCubit.load(); + } on ApiException { + messenger.showSnackBar( + const SnackBar( + behavior: SnackBarBehavior.floating, + content: Text('Could not register for the event.'), + ), + ); + } + await _eventCubit.load(); + } } - Widget _makeJoinQueueButton(Event event) { - return ElevatedButton.icon( - statesController: _buttonControler, - onPressed: !_buttonControler.value.contains(WidgetState.disabled) - ? () async { - final messenger = ScaffoldMessenger.of(context); - final calendarCubit = BlocProvider.of(context); - final router = GoRouter.of(context); - try { - final registration = await _eventCubit.register(); - if (event.hasFields) { - router.pushNamed( - 'event-registration', - pathParameters: { - 'eventPk': event.pk.toString(), - 'registrationPk': registration.pk.toString(), - }, - ); - } - calendarCubit.load(); - } on ApiException { - messenger.showSnackBar( - const SnackBar( - behavior: SnackBarBehavior.floating, - content: Text( - 'Could not join the waiting list for the event.', - ), - ), - ); - } - await _eventCubit.load(); - } - : null, - icon: const Icon(Icons.create_outlined), - label: const Text('JOIN QUEUE'), - ); + void joinQueue(Event event) async { + final messenger = ScaffoldMessenger.of(context); + final calendarCubit = BlocProvider.of(context); + final router = GoRouter.of(context); + try { + final registration = await _eventCubit.register(); + if (event.hasFields) { + router.pushNamed( + 'event-registration', + pathParameters: { + 'eventPk': event.pk.toString(), + 'registrationPk': registration.pk.toString(), + }, + ); + } + calendarCubit.load(); + } on ApiException { + messenger.showSnackBar( + const SnackBar( + behavior: SnackBarBehavior.floating, + content: Text('Could not join the waiting list for the event.'), + ), + ); + } + await _eventCubit.load(); } Widget _makeCancelRegistrationButton(Event event, String warningText) { @@ -766,14 +777,20 @@ class _EventScreenState extends State { ); } - Widget _makeFoodShiftButton(ShiftInfo shift) { - // TODO: Do something with the start/end time. gray it out?? + Widget _makeFoodShiftButton( + ShiftInfo shift, + WidgetStatesController controler, + DateTime? nextChange, + ) { return SizedBox( width: double.infinity, - child: ElevatedButton.icon( + child: TimedIconButton( + controller: controler, onPressed: () => context.pushNamed('sales-shift', extra: shift.pk), icon: const Icon(Icons.local_pizza), - label: Text('ORDER FOOD (${shift.title})'), + labelText: 'ORDER FOOD (${shift.title})', + countdownPrefix: 'ORDER ${shift.title} IN', + opens: nextChange, ), ); } diff --git a/lib/ui/widgets/timed_state_enable.dart b/lib/ui/widgets/timed_state_enable.dart new file mode 100644 index 000000000..51a6dedbb --- /dev/null +++ b/lib/ui/widgets/timed_state_enable.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + +// ignore: must_be_immutable +class TimedEnableButton extends StatefulWidget { + final DateTime? open; + final DateTime? close; + + final Widget Function(BuildContext, WidgetStatesController, DateTime?) + builder; + final void Function()? onOpen; + final void Function()? onClose; + + TimedEnableButton({ + super.key, + this.open, + this.onOpen, + this.close, + this.onClose, + required this.builder, + }) : assert(open == null || (close?.isAfter(open) ?? true)); + + @override + State createState() => _TimedEnableButton(); +} + +class _TimedEnableButton extends State { + final WidgetStatesController controler = WidgetStatesController(); + + void delayedSet(DateTime deadline, bool close) { + Future.delayed(deadline.difference(DateTime.now()), () { + if (mounted) { + setState(() { + controler.update(WidgetState.disabled, close); + }); + } + }); + } + + @override + Widget build(BuildContext context) { + DateTime? open = widget.open; + DateTime? close = widget.close; + // Assume its opened until proven otherwise + controler.update(WidgetState.disabled, false); + + DateTime? nextChange; + // We know close is after before, so this is valid + if (open != null) { + if (open.isAfter(DateTime.now())) { + controler.update(WidgetState.disabled, true); + delayedSet(open, false); + nextChange = open; + } + } + if (close != null) { + if (close.isAfter(DateTime.now())) { + delayedSet(close, true); + nextChange = close; + } else { + controler.update(WidgetState.disabled, true); + } + } + + return widget.builder(context, controler, nextChange); + } +} + +// `TimedIconButton` shows a generic button, where the text changes based on +// if it is build before or after `opens`. If now is before `opens` it will show +// the prefit with a countdown. See `CountdownText` for more details. If `now` is +// after `opens`, it shows `labelText` as label. This is usefull in combination with +// `TimedEnableButton`, which rebuilds its children and provides the `DateTime` for +// the next change. This `DateTime` can be passed to `opens` +class TimedIconButton extends StatelessWidget { + final WidgetStatesController controller; + final Function onPressed; + final Widget icon; + final String labelText; + final String countdownPrefix; + final DateTime? opens; + + const TimedIconButton({ + super.key, + required this.controller, + required this.onPressed, + required this.icon, + required this.labelText, + required this.countdownPrefix, + this.opens, + }); + + @override + Widget build(BuildContext context) { + late final Widget label; + // There is also a countdown for the close of registration + if (opens?.isBefore(DateTime.now()) ?? true) { + label = Text(labelText); + } else { + label = CountdownText(deadline: opens!, prefix: countdownPrefix); + } + + return ElevatedButton.icon( + statesController: controller, + onPressed: !controller.value.contains(WidgetState.disabled) + ? () => onPressed + : null, + icon: icon, + label: label, + ); + } +} + +// CountdownText is a widget that shows a message with a countdown +// as such: '${prefix} ${timer}', where timer is `deadline-Datetime.now()` +class CountdownText extends StatelessWidget { + final DateTime deadline; + final String prefix; + + const CountdownText({ + super.key, + required this.deadline, + required this.prefix, + }); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: Stream.periodic(const Duration(seconds: 1)), + builder: (context, snapshot) { + Duration timer = deadline.difference(DateTime.now()); + return Text( + '$prefix ${timer.inMinutes}:${(timer.inSeconds.remainder(60))}', + ); + }, + ); + } +} From 2346502a2dd0596fd4e17d512e40d02bab5ae5c2 Mon Sep 17 00:00:00 2001 From: Jaap Aarts Date: Sun, 9 Aug 2026 16:17:25 +0200 Subject: [PATCH 2/2] Fix lint --- lib/ui/widgets/timed_state_enable.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/ui/widgets/timed_state_enable.dart b/lib/ui/widgets/timed_state_enable.dart index 51a6dedbb..8c05f6170 100644 --- a/lib/ui/widgets/timed_state_enable.dart +++ b/lib/ui/widgets/timed_state_enable.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; // ignore: must_be_immutable class TimedEnableButton extends StatefulWidget {