diff --git a/lib/blocs.dart b/lib/blocs.dart index 265a64728..a74947119 100644 --- a/lib/blocs.dart +++ b/lib/blocs.dart @@ -5,7 +5,5 @@ export 'blocs/detail_state.dart'; export 'blocs/food_cubit.dart'; export 'blocs/full_member_cubit.dart'; export 'blocs/list_state.dart'; -export 'blocs/payment_user_cubit.dart'; -export 'blocs/registration_fields_cubit.dart'; export 'blocs/theme_cubit.dart'; export 'blocs/welcome_cubit.dart'; diff --git a/lib/blocs/payment_user_cubit.dart b/lib/blocs/payment_user_cubit.dart deleted file mode 100644 index ce3d72500..000000000 --- a/lib/blocs/payment_user_cubit.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:equatable/equatable.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:meta/meta.dart'; -import 'package:reaxit/api/api_repository.dart'; -import 'package:reaxit/api/exceptions.dart'; -import 'package:reaxit/models.dart'; - -class PaymentUserState extends Equatable { - /// This can only be null when [isLoading] or [hasException] is true. - final PaymentUser? user; - - /// This can only be null when [isLoading] or [hasException] is true. - final List? payments; - - /// A message describing why there are no foodEvents. - final String? message; - - final bool isLoading; - - bool get hasException => message != null; - - @protected - const PaymentUserState({ - required this.user, - required this.payments, - required this.isLoading, - required this.message, - }) : assert( - (user != null && payments != null) || isLoading || message != null, - 'user and payments can only be null ' - 'when isLoading or hasException is true.', - ); - - @override - List get props => [user, payments, message, isLoading]; - - PaymentUserState copyWith({ - PaymentUser? user, - List? payments, - bool? isLoading, - String? message, - }) => - PaymentUserState( - user: user ?? this.user, - payments: payments ?? this.payments, - isLoading: isLoading ?? this.isLoading, - message: message ?? this.message, - ); - - const PaymentUserState.result({ - required PaymentUser this.user, - required List this.payments, - }) : message = null, - isLoading = false; - - const PaymentUserState.loading({this.user, this.payments}) - : message = null, - isLoading = true; - - const PaymentUserState.failure({required String this.message}) - : user = null, - payments = null, - isLoading = false; -} - -class PaymentUserCubit extends Cubit { - final ApiRepository api; - - PaymentUserCubit(this.api) : super(const PaymentUserState.loading()); - - Future load() async { - emit(state.copyWith(isLoading: true)); - try { - final paymentUser = await api.getPaymentUser(); - final payments = await api - .getPayments(type: [PaymentType.tpayPayment], settled: false); - - emit(PaymentUserState.result( - user: paymentUser, payments: payments.results)); - } on ApiException catch (exception) { - emit(PaymentUserState.failure(message: exception.message)); - } - } -} diff --git a/lib/blocs/registration_fields_cubit.dart b/lib/blocs/registration_fields_cubit.dart deleted file mode 100644 index b1e85d735..000000000 --- a/lib/blocs/registration_fields_cubit.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:reaxit/api/api_repository.dart'; -import 'package:reaxit/api/exceptions.dart'; -import 'package:reaxit/blocs.dart'; -import 'package:reaxit/models.dart'; - -typedef RegistrationFieldsState = DetailState>; - -class RegistrationFieldsCubit extends Cubit { - final ApiRepository api; - - RegistrationFieldsCubit(this.api) : super(const LoadingState()); - - Future load({required int eventPk, required int registrationPk}) async { - emit(LoadingState.from(state)); - try { - final fields = await api.getRegistrationFields( - eventPk: eventPk, - registrationPk: registrationPk, - ); - emit(ResultState(fields)); - } on ApiException catch (exception) { - emit(ErrorState(exception.getMessage( - notFound: 'The registration does not ' - 'exist or does not have any fields.', - ))); - } - } - - Future update({ - required int eventPk, - required int registrationPk, - required Map fields, - }) async { - await api.updateRegistrationFields( - eventPk: eventPk, - registrationPk: registrationPk, - fields: fields, - ); - } -} diff --git a/lib/main.dart b/lib/main.dart index 8c7425d21..7bf9db44a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -308,11 +308,6 @@ class _ThaliAppState extends State { value: apiRepository, child: MultiBlocProvider( providers: [ - BlocProvider( - create: (_) => - PaymentUserCubit(apiRepository)..load(), - lazy: false, - ), BlocProvider( create: (_) => FullMemberCubit(apiRepository)..load(), diff --git a/lib/routes.dart b/lib/routes.dart index ca5826af9..0cbb9cfea 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -96,46 +96,6 @@ final List routes = [ ), ], ), - GoRoute( - path: '/association/committees/:groupSlug', - redirect: (context, state) => - '/groups/committees/${state.pathParameters['groupSlug']}', - ), - GoRoute( - path: '/association/societies/:groupSlug', - redirect: (context, state) => - '/groups/societies/${state.pathParameters['groupSlug']}', - ), - GoRoute( - path: '/association/boards/:groupSlug', - redirect: (context, state) => - '/groups/boards/${state.pathParameters['groupSlug']}', - ), - GoRoute( - path: '/association/committees', - redirect: (context, state) => '/groups/committees', - ), - GoRoute( - path: '/association/societies', - redirect: (context, state) => '/groups/societies', - ), - GoRoute( - path: '/association/boards', - redirect: (context, state) => '/groups/boards', - ), - GoRoute( - path: '/pizzas', - name: 'food', - pageBuilder: (context, state) { - return MaterialPage( - key: state.pageKey, - child: FoodScreen( - pk: (state.extra as Event?)?.foodEvent, - event: state.extra as Event?, - ), - ); - }, - ), GoRoute( path: '/login', name: 'login', @@ -144,9 +104,4 @@ final List routes = [ child: const LoginScreen(), ), ), - GoRoute( - path: '/pay', - name: 'pay', - pageBuilder: (context, state) => - MaterialPage(key: state.pageKey, child: PayScreen())), ]; diff --git a/lib/ui/screens.dart b/lib/ui/screens.dart index 5acb8e5df..29c2443a4 100644 --- a/lib/ui/screens.dart +++ b/lib/ui/screens.dart @@ -1,7 +1,4 @@ export 'screens/album_screen.dart'; export 'screens/albums_screen.dart'; -export 'screens/food_screen.dart'; export 'screens/login_screen.dart'; -export 'screens/registration_screen.dart'; export 'screens/welcome_screen.dart'; -export 'screens/pay_screen.dart'; diff --git a/lib/ui/screens/food_screen.dart b/lib/ui/screens/food_screen.dart deleted file mode 100644 index fe90d9196..000000000 --- a/lib/ui/screens/food_screen.dart +++ /dev/null @@ -1,498 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; -import 'package:intl/intl.dart'; -import 'package:reaxit/api/api_repository.dart'; -import 'package:reaxit/api/exceptions.dart'; -import 'package:reaxit/blocs.dart'; -import 'package:reaxit/models.dart'; -import 'package:reaxit/ui/widgets.dart'; - -class FoodScreen extends StatefulWidget { - /// The pk that of the [FoodEvent] to show. - /// If null, the current food event is found and used. - final int? pk; - - /// The [Event] to which the [FoodEvent] belongs. - final Event? event; - - FoodScreen({this.pk, this.event}) : super(key: ValueKey(pk)); - - @override - State createState() => _FoodScreenState(); -} - -class _FoodScreenState extends State { - static final timeFormatter = DateFormat('HH:mm'); - - late final FoodCubit _foodCubit; - - final ScrollController _controller = ScrollController(); - - @override - void initState() { - _foodCubit = FoodCubit( - RepositoryProvider.of(context), - foodEventPk: widget.pk, - )..load(); - super.initState(); - } - - @override - void dispose() { - _controller.dispose(); - _foodCubit.close(); - super.dispose(); - } - - Widget _makeEventInfo(FoodEvent foodEvent) { - var start = timeFormatter.format(foodEvent.start.toLocal()); - var end = timeFormatter.format(foodEvent.end.toLocal()); - - Text subtitle; - if (!foodEvent.hasStarted()) { - subtitle = Text('It will be possible to order from $start.'); - } else if (foodEvent.hasEnded()) { - subtitle = Text('It was possible to order until $end.'); - } else { - subtitle = Text('You can order until $end.'); - } - - return Column( - children: [ - Text( - foodEvent.title, - style: Theme.of(context).textTheme.headlineSmall, - textAlign: TextAlign.center, - ), - subtitle, - ], - ); - } - - Widget _makeOrderInfo(FoodEvent foodEvent) { - Widget? orderCard; - if (foodEvent.hasOrder) { - var order = foodEvent.order!; - - // Whether at least one button is shown, so a divider is needed. - var addDivider = false; - - late Widget cancelButton; - if (foodEvent.canChangeOrder()) { - addDivider = true; - cancelButton = SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: () async { - final messenger = ScaffoldMessenger.of(context); - final confirmed = await showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: const Text('Cancel order'), - content: Text( - 'Are you sure you want to cancel your order?', - style: Theme.of(context).textTheme.bodyMedium, - ), - actions: [ - TextButton.icon( - onPressed: () => Navigator.of( - context, - rootNavigator: true, - ).pop(false), - icon: const Icon(Icons.clear), - label: const Text('No'), - ), - ElevatedButton.icon( - onPressed: () => Navigator.of( - context, - rootNavigator: true, - ).pop(true), - icon: const Icon(Icons.check), - label: const Text('YES'), - ), - ], - ); - }, - ); - - if (confirmed ?? false) { - try { - await _foodCubit.cancelOrder(); - } on ApiException { - messenger.showSnackBar(const SnackBar( - behavior: SnackBarBehavior.floating, - content: Text('Could not cancel your order.'), - )); - } - } - }, - icon: const Icon(Icons.cancel), - label: const Text('CANCEL ORDER'), - ), - ); - } else { - cancelButton = const SizedBox.shrink(); - } - - late Widget payButton; - if (order.isPaid || !order.tpayAllowed) { - payButton = const SizedBox.shrink(key: ValueKey(false)); - } else { - payButton = SizedBox( - width: double.infinity, - key: const ValueKey(true), - child: TPayButton( - onPay: () => _foodCubit.thaliaPayOrder(orderPk: order.pk), - confirmationMessage: 'Are you sure you ' - 'want to pay €${order.product.price} ' - 'for your "${order.product.name}"?', - failureMessage: 'Could not pay your order.', - successMessage: 'Paid your order with Thalia Pay.', - amount: order.product.price, - ), - ); - } - - orderCard = Card( - child: Column( - children: [ - AspectRatio( - aspectRatio: 1, - child: AnimatedContainer( - decoration: BoxDecoration( - color: order.isPaid - ? Colors.green.shade200 - : Colors.red.shade700, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(4), - topRight: Radius.circular(4), - ), - ), - duration: const Duration(milliseconds: 300), - padding: const EdgeInsets.all(32), - child: FittedBox( - fit: BoxFit.contain, - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: order.isPaid - ? Icon( - Icons.check_circle_outline, - color: Colors.green.shade400, - ) - : Icon( - Icons.highlight_off, - color: Colors.red.shade900, - ), - ), - ), - ), - ), - Padding( - padding: const EdgeInsets.only( - top: 8, - left: 16, - right: 16, - bottom: 16, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - textBaseline: TextBaseline.alphabetic, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.baseline, - children: [ - Expanded( - child: Text( - order.product.name, - style: Theme.of(context).textTheme.titleLarge, - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - ), - Text( - order.isPaid - ? 'has been paid' - : 'not yet paid: €${order.product.price}', - style: Theme.of(context).textTheme.bodyMedium, - ), - ], - ), - if (order.product.description.isNotEmpty) ...[ - const Divider(), - Text(order.product.description), - ], - AnimatedSize( - curve: Curves.ease, - duration: const Duration(milliseconds: 200), - child: AnimatedSwitcher( - switchInCurve: Curves.ease, - switchOutCurve: Curves.ease, - duration: const Duration(milliseconds: 200), - child: addDivider - ? const Divider() - : const SizedBox.shrink(), - ), - ), - AnimatedSize( - curve: Curves.ease, - duration: const Duration(milliseconds: 200), - child: AnimatedSwitcher( - switchInCurve: Curves.ease, - switchOutCurve: Curves.ease, - duration: const Duration(milliseconds: 200), - transitionBuilder: (child, animation) { - return ScaleTransition( - scale: animation, - child: child, - ); - }, - child: cancelButton, - ), - ), - AnimatedSize( - curve: Curves.ease, - duration: const Duration(milliseconds: 200), - child: AnimatedSwitcher( - switchInCurve: Curves.ease, - switchOutCurve: Curves.ease, - duration: const Duration(milliseconds: 200), - transitionBuilder: (child, animation) { - return ScaleTransition( - scale: animation, - child: child, - ); - }, - child: payButton, - ), - ), - ], - ), - ), - ], - ), - ); - } - - return AnimatedSize( - curve: Curves.ease, - duration: const Duration(milliseconds: 200), - alignment: Alignment.topCenter, - child: AnimatedSwitcher( - switchInCurve: Curves.ease, - switchOutCurve: Curves.ease, - duration: const Duration(milliseconds: 200), - transitionBuilder: (Widget child, Animation animation) { - return ScaleTransition(scale: animation, child: child); - }, - child: orderCard ?? const SizedBox.shrink(), - ), - ); - } - - @override - Widget build(BuildContext context) { - return BlocProvider.value( - value: _foodCubit, - child: BlocConsumer( - listenWhen: (previous, current) { - if (previous.foodEvent != null && current.foodEvent != null) { - if (current.foodEvent!.hasOrder) { - return previous.foodEvent!.order != current.foodEvent!.order; - } - } - return false; - }, - listener: (context, state) { - _controller.animateTo( - 0, - duration: const Duration(milliseconds: 500), - curve: Curves.ease, - ); - }, - builder: (context, state) { - if (state.hasException) { - return Scaffold( - appBar: ThaliaAppBar( - title: const Text('ORDER FOOD'), - ), - body: RefreshIndicator( - onRefresh: () => _foodCubit.load(), - child: ErrorScrollView(state.message!), - ), - ); - } else if (state.isLoading && - (state.foodEvent == null || state.products == null)) { - return Scaffold( - appBar: ThaliaAppBar( - title: const Text('ORDER FOOD'), - ), - body: const Center(child: CircularProgressIndicator()), - ); - } else { - final foodEvent = state.foodEvent!; - final products = state.products; - return Scaffold( - appBar: ThaliaAppBar( - title: const Text('ORDER FOOD'), - collapsingActions: [ - IconAppbarAction( - 'ADMIN', - Icons.settings, - () => context.pushNamed( - 'food-admin', - extra: foodEvent.pk, - ), - tooltip: 'food admin', - ) - ], - ), - body: RefreshIndicator( - onRefresh: () => _foodCubit.load(), - child: ListView( - key: const PageStorageKey('food'), - controller: _controller, - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.all(16), - children: [ - _makeEventInfo(foodEvent), - _makeOrderInfo(foodEvent), - const Divider(), - Card( - child: Column( - children: ListTile.divideTiles( - context: context, - tiles: [ - for (final product in products!) - _ProductTile(product) - ], - ).toList(), - ), - ), - ], - ), - ), - ); - } - }, - ), - ); - } -} - -class _ProductTile extends StatefulWidget { - final Product product; - - _ProductTile(this.product) : super(key: ValueKey(product.pk)); - - @override - __ProductTileState createState() => __ProductTileState(); -} - -class __ProductTileState extends State<_ProductTile> { - @override - Widget build(BuildContext context) { - return ListTile( - title: Text.rich( - TextSpan( - children: [ - TextSpan(text: '${widget.product.name} '), - TextSpan( - text: '€${widget.product.price}', - style: Theme.of(context).textTheme.bodySmall!.copyWith( - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - ], - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: widget.product.description.isNotEmpty - ? Text(widget.product.description) - : null, - trailing: BlocBuilder( - builder: (context, state) { - return ElevatedButton( - onPressed: state.foodEvent?.canOrder() ?? false - ? () { - if (state.foodEvent!.hasOrder) { - _changeOrder(state.foodEvent!); - } else { - _placeOrder(state.foodEvent!); - } - } - : null, - child: const Icon(Icons.shopping_bag), - ); - }, - ), - ); - } - - Future _placeOrder(FoodEvent foodEvent) async { - final messenger = ScaffoldMessenger.of(context); - try { - await BlocProvider.of(context).placeOrder( - productPk: widget.product.pk, - ); - } on ApiException { - messenger.showSnackBar(const SnackBar( - behavior: SnackBarBehavior.floating, - content: Text('Could not place your order.'), - )); - } - } - - Future _changeOrder(FoodEvent foodEvent) async { - final messenger = ScaffoldMessenger.of(context); - final foodCubit = BlocProvider.of(context); - final confirmed = await showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: const Text('Change order'), - content: Text( - 'Are you sure you want to change your order?', - style: Theme.of(context).textTheme.bodyMedium, - ), - actions: [ - TextButton.icon( - onPressed: () => Navigator.of( - context, - rootNavigator: true, - ).pop(false), - icon: const Icon(Icons.clear), - label: const Text('No'), - ), - ElevatedButton.icon( - onPressed: () => Navigator.of( - context, - rootNavigator: true, - ).pop(true), - icon: const Icon(Icons.check), - label: const Text('YES'), - ), - ], - ); - }, - ); - - if (confirmed ?? false) { - try { - await foodCubit.changeOrder( - productPk: widget.product.pk, - ); - } on ApiException { - messenger.showSnackBar(const SnackBar( - behavior: SnackBarBehavior.floating, - content: Text('Could not change your order.'), - )); - } - } - } -} diff --git a/lib/ui/screens/pay_screen.dart b/lib/ui/screens/pay_screen.dart deleted file mode 100644 index 622eca65f..000000000 --- a/lib/ui/screens/pay_screen.dart +++ /dev/null @@ -1,97 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:reaxit/blocs/payment_user_cubit.dart'; -import 'package:reaxit/models.dart'; -import 'package:reaxit/ui/widgets.dart'; -import 'package:reaxit/ui/widgets/pay_tile.dart'; - -import '../../models/payment.dart'; - -class PayScreen extends StatelessWidget { - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: ThaliaAppBar( - title: const Text('THALIA PAY'), - ), - drawer: MenuDrawer(), - body: RefreshIndicator( - onRefresh: () => BlocProvider.of(context).load(), - child: BlocBuilder( - builder: (context, state) { - if (state.isLoading) { - return const Center( - child: CircularProgressIndicator(), - ); - } else if (state.hasException) { - return ErrorScrollView(state.message!); - } else { - return _Body(payments: state.payments!); - } - }), - ), - ); - } -} - -class _Body extends StatelessWidget { - final List payments; - - const _Body({ - required this.payments, - }); - - @override - Widget build(BuildContext context) { - return ListView.builder( - itemCount: payments.length + 1, - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - itemBuilder: (BuildContext context, int index) { - if (index == 0) { - return const _Header(); - } else { - return PayTile(payment: payments[index - 1]); - } - }, - ); - } -} - -class _Header extends StatelessWidget { - const _Header(); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - final String balance; - if (state.isLoading) { - balance = '-'; - } else { - balance = '€ ${state.user!.tpayBalance}'; - } - - return Material( - type: MaterialType.card, - color: Theme.of(context).canvasColor, - borderRadius: const BorderRadius.all(Radius.circular(2)), - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('CURRENT BALANCE: $balance', - textAlign: TextAlign.center, - style: const TextStyle( - fontWeight: FontWeight.bold, - )), - ], - ), - ), - ); - }, - ); - } -} diff --git a/lib/ui/screens/registration_screen.dart b/lib/ui/screens/registration_screen.dart deleted file mode 100644 index 452d7a820..000000000 --- a/lib/ui/screens/registration_screen.dart +++ /dev/null @@ -1,234 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:reaxit/api/api_repository.dart'; -import 'package:reaxit/api/exceptions.dart'; -import 'package:reaxit/blocs.dart'; -import 'package:reaxit/models.dart'; -import 'package:reaxit/ui/widgets.dart'; - -class RegistrationScreen extends StatefulWidget { - final int eventPk; - final int registrationPk; - - RegistrationScreen({required this.eventPk, required this.registrationPk}) - : super(key: ValueKey(registrationPk)); - - @override - State createState() => _RegistrationScreenState(); -} - -class _RegistrationScreenState extends State { - late final RegistrationFieldsCubit _registrationFieldsCubit; - - final _formKey = GlobalKey(); - - @override - void initState() { - _registrationFieldsCubit = RegistrationFieldsCubit( - RepositoryProvider.of(context), - )..load( - eventPk: widget.eventPk, - registrationPk: widget.registrationPk, - ); - super.initState(); - } - - @override - void dispose() { - _registrationFieldsCubit.close(); - super.dispose(); - } - - Future _submit(Map result) async { - if (_formKey.currentState!.validate()) { - _formKey.currentState!.save(); - - final messenger = ScaffoldMessenger.of(context); - - try { - await _registrationFieldsCubit.update( - eventPk: widget.eventPk, - registrationPk: widget.registrationPk, - fields: result, - ); - - if (mounted) Navigator.of(context).pop(); - - messenger.showSnackBar( - const SnackBar( - behavior: SnackBarBehavior.floating, - content: Text( - 'Your registration has been updated.', - ), - ), - ); - } on ApiException { - messenger.showSnackBar( - const SnackBar( - behavior: SnackBarBehavior.floating, - content: Text( - 'Could not update your registration.', - ), - ), - ); - } - } - } - - // TODO: it seems much better to allow the registrationfields to convert to widgets - // themselves. - Widget _registrationfieldToWidget(RegistrationField field) => switch (field) { - TextRegistrationField _ => Column( - children: [ - ListTile( - title: Text(field.label), - subtitle: field.description.isNotEmpty - ? Text(field.description) - : null, - ), - Padding( - padding: const EdgeInsets.only( - left: 16, - bottom: 16, - right: 16, - ), - child: TextFormField( - initialValue: field.value, - minLines: 1, - maxLines: 5, - decoration: InputDecoration( - labelText: - field.isRequired ? '${field.label} *' : field.label, - hintText: 'Lorem ipsum...', - ), - validator: (value) { - if (field.isRequired && (value == null || value.isEmpty)) { - return 'Please fill in this field.'; - } - return null; - }, - onSaved: (newValue) => field.value = newValue, - ), - ), - ], - ), - IntegerRegistrationField _ => Column( - children: [ - ListTile( - dense: field.description.isEmpty, - title: Text(field.label), - subtitle: field.description.isNotEmpty - ? Text(field.description) - : null, - ), - Padding( - padding: const EdgeInsets.only( - left: 16, - right: 16, - bottom: 16, - ), - child: TextFormField( - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - decoration: InputDecoration( - labelText: - field.isRequired ? '${field.label} *' : field.label, - hintText: '123...', - ), - initialValue: field.value?.toString(), - validator: (value) { - if (field.isRequired && (value == null || value.isEmpty)) { - return 'Please fill in this field.'; - } - return null; - }, - onSaved: (newValue) => field.value = int.tryParse(newValue!), - ), - ), - ], - ), - CheckboxRegistrationField _ => Padding( - padding: const EdgeInsets.only(bottom: 16), - child: _CheckboxFormField( - initialValue: field.value ?? false, - onSaved: (newValue) => field.value = newValue, - title: Text(field.label), - subtitle: - field.description.isNotEmpty ? Text(field.description) : null, - ), - ), - }; - - @override - Widget build(BuildContext context) { - return BlocBuilder( - bloc: _registrationFieldsCubit, - builder: (context, state) { - Widget body = switch (state) { - ErrorState(message: var message) => ErrorCenter.fromMessage(message), - LoadingState _ => const Center(child: CircularProgressIndicator()), - ResultState(result: var result) => SingleChildScrollView( - child: Form( - key: _formKey, - child: Column( - children: [ - ...result.entries.map( - (entry) => _registrationfieldToWidget(entry.value)), - Padding( - padding: const EdgeInsets.all(16), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton.icon( - onPressed: () { - _formKey.currentState!.reset(); - }, - icon: const Icon(Icons.restore_page_outlined), - label: const Text('RESTORE'), - ), - const SizedBox(width: 16), - ElevatedButton.icon( - onPressed: () async => await _submit(result), - icon: const Icon(Icons.check), - label: const Text('SUBMIT'), - ), - ], - ), - ), - ], - ), - ), - ) - }; - return Scaffold( - appBar: ThaliaAppBar( - title: const Text('REGISTRATION'), - leading: const CloseButton(), - ), - body: body, - ); - }, - ); - } -} - -class _CheckboxFormField extends FormField { - _CheckboxFormField({ - Widget? title, - Widget? subtitle, - super.onSaved, - super.initialValue = false, - }) : super( - builder: (FormFieldState state) { - return CheckboxListTile( - dense: state.hasError, - title: title, - value: state.value, - onChanged: state.didChange, - subtitle: subtitle, - controlAffinity: ListTileControlAffinity.leading, - ); - }, - ); -} diff --git a/lib/ui/widgets.dart b/lib/ui/widgets.dart index 6c4d8e505..78b94a813 100644 --- a/lib/ui/widgets.dart +++ b/lib/ui/widgets.dart @@ -8,6 +8,5 @@ export 'widgets/member_tile.dart'; export 'widgets/menu_drawer.dart'; export 'widgets/push_notification_dialog.dart'; export 'widgets/push_notification_overlay.dart'; -export 'widgets/tpay_button.dart'; export 'widgets/sortbutton.dart'; export 'widgets/filter_popup.dart'; diff --git a/lib/ui/widgets/tpay_button.dart b/lib/ui/widgets/tpay_button.dart deleted file mode 100644 index 9e4703ba8..000000000 --- a/lib/ui/widgets/tpay_button.dart +++ /dev/null @@ -1,227 +0,0 @@ -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:reaxit/api/exceptions.dart'; -import 'package:reaxit/blocs.dart'; -import 'package:url_launcher/url_launcher.dart'; -import 'package:reaxit/config.dart'; - -class TPayButton extends StatefulWidget { - /// A function that performs the payment. If null, the button is disabled. - /// - /// This function can also perform extra logic before or after the payment. - final Future Function()? onPay; - - /// The message to display in the confirmation dialog. - /// - /// This can only be null if [onPay] is also null. - final String? confirmationMessage; - - /// The message to display in a snackbar if the payment fails. - /// - /// This can only be null if [onPay] is also null. - final String? failureMessage; - - /// The message to display in a snackbar if the payment succeeds. - /// - /// This can only be null if [onPay] is also null. - final String? successMessage; - - /// The amount of money to pay. - /// - /// This can only be null if [onPay] is also null. - final String? amount; - - const TPayButton({ - super.key, - required this.onPay, - required this.confirmationMessage, - required this.failureMessage, - required this.successMessage, - required this.amount, - }) : assert(amount != null || onPay == null), - assert(confirmationMessage != null || onPay == null), - assert(failureMessage != null || onPay == null), - assert(successMessage != null || onPay == null); - - const TPayButton.disabled({String? amount}) - : this( - onPay: null, - confirmationMessage: null, - failureMessage: null, - successMessage: null, - amount: amount, - ); - - @override - State createState() => _TPayButtonState(); -} - -class _TPayButtonState extends State { - bool tmpDisabled = false; - - Future _showConfirmDialog(String confirmationMessage) async { - final confirmed = await showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: const Text('Confirm payment'), - content: Text( - confirmationMessage, - style: Theme.of(context).textTheme.bodyMedium, - ), - actions: [ - TextButton.icon( - onPressed: () => Navigator.of( - context, - rootNavigator: true, - ).pop(false), - icon: const Icon(Icons.clear), - label: const Text('CANCEL'), - ), - ElevatedButton.icon( - onPressed: () => Navigator.of( - context, - rootNavigator: true, - ).pop(true), - icon: const Icon(Icons.check), - label: const Text('YES'), - ), - ], - ); - }, - ); - - return confirmed ?? false; - } - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - const icon = Icon(Icons.euro); - final buttonLabel = Text( - widget.amount != null - ? 'THALIA PAY: €${widget.amount}' - : 'THALIA PAY', - ); - - if (widget.onPay == null || tmpDisabled) { - // The button is disabled. - return ElevatedButton.icon( - onPressed: null, - icon: icon, - label: buttonLabel, - ); - // TODO: provide custom tooltip. - } else if (state.isLoading) { - // PaymentUser loading. - return ElevatedButton.icon( - onPressed: null, - icon: icon, - label: buttonLabel, - ); - } else if (state.hasException) { - // PaymentUser couldn't load. - return ElevatedButton.icon( - onPressed: null, - icon: icon, - label: buttonLabel, - ); - } else { - final paymentUser = state.user!; - if (!paymentUser.tpayAllowed) { - // TPay not allowed for the user. - return Tooltip( - message: 'You are not allowed to use Thalia Pay.', - child: ElevatedButton.icon( - onPressed: null, - icon: icon, - label: buttonLabel, - ), - ); - } else if (!paymentUser.tpayEnabled) { - // TPay not yet enabled. - final url = Config.of(context).tpaySignDirectDebitMandateUrl; - final message = TextSpan( - children: [ - const TextSpan( - text: 'To start using Thalia Pay, ' - 'sign a direct debit mandate on ', - ), - TextSpan( - text: 'the website', - recognizer: TapGestureRecognizer() - ..onTap = () async { - final messenger = ScaffoldMessenger.of(context); - try { - await launchUrl( - url, - mode: LaunchMode.externalApplication, - ); - } catch (_) { - messenger.showSnackBar( - SnackBar( - behavior: SnackBarBehavior.floating, - content: Text( - 'Could not open "${url.toString()}".', - ), - ), - ); - } - }, - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - ), - ), - const TextSpan(text: '.'), - ], - ); - - return Tooltip( - richMessage: message, - child: ElevatedButton.icon( - onPressed: null, - icon: icon, - label: buttonLabel, - ), - ); - } else { - // TPay possible. - final onPay = widget.onPay!; - final successMessage = widget.successMessage!; - final failureMessage = widget.failureMessage!; - final confirmationMessage = widget.confirmationMessage!; - return ElevatedButton.icon( - onPressed: () async { - final messenger = ScaffoldMessenger.of(context); - if (await _showConfirmDialog(confirmationMessage)) { - setState(() { - tmpDisabled = true; - }); - try { - await onPay(); - messenger.showSnackBar(SnackBar( - behavior: SnackBarBehavior.floating, - content: Text(successMessage), - )); - } on ApiException { - messenger.showSnackBar(SnackBar( - behavior: SnackBarBehavior.floating, - content: Text(failureMessage), - )); - } - setState(() { - tmpDisabled = false; - }); - } - }, - icon: icon, - label: buttonLabel, - ); - } - } - }, - ); - } -} diff --git a/test/mocks.dart b/test/mocks.dart index 8ea1dc5bb..2ebe5af37 100644 --- a/test/mocks.dart +++ b/test/mocks.dart @@ -5,7 +5,6 @@ import 'package:reaxit/blocs.dart'; @GenerateMocks([ AuthCubit, ApiRepository, - PaymentUserCubit, WelcomeCubit, ]) void main() {} diff --git a/test/mocks.mocks.dart b/test/mocks.mocks.dart index 41bb21d13..40e492e91 100644 --- a/test/mocks.mocks.dart +++ b/test/mocks.mocks.dart @@ -9,7 +9,6 @@ import 'package:flutter_bloc/flutter_bloc.dart' as _i9; import 'package:mockito/mockito.dart' as _i1; import 'package:reaxit/api/api_repository.dart' as _i5; import 'package:reaxit/blocs/auth_cubit.dart' as _i2; -import 'package:reaxit/blocs/payment_user_cubit.dart' as _i6; import 'package:reaxit/blocs/welcome_cubit.dart' as _i7; import 'package:reaxit/config.dart' as _i3; import 'package:reaxit/models.dart' as _i4; @@ -208,17 +207,6 @@ class _FakeApiRepository_17 extends _i1.SmartFake implements _i5.ApiRepository { ); } -class _FakePaymentUserState_18 extends _i1.SmartFake - implements _i6.PaymentUserState { - _FakePaymentUserState_18( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - class _FakeWelcomeState_19 extends _i1.SmartFake implements _i7.WelcomeState { _FakeWelcomeState_19( Object parent, @@ -1585,106 +1573,6 @@ class MockApiRepository extends _i1.Mock implements _i5.ApiRepository { ) as _i8.Future<_i4.ListResponse<_i4.Payment>>); } -/// A class which mocks [PaymentUserCubit]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockPaymentUserCubit extends _i1.Mock implements _i6.PaymentUserCubit { - MockPaymentUserCubit() { - _i1.throwOnMissingStub(this); - } - - @override - _i5.ApiRepository get api => (super.noSuchMethod( - Invocation.getter(#api), - returnValue: _FakeApiRepository_17( - this, - Invocation.getter(#api), - ), - ) as _i5.ApiRepository); - @override - _i6.PaymentUserState get state => (super.noSuchMethod( - Invocation.getter(#state), - returnValue: _FakePaymentUserState_18( - this, - Invocation.getter(#state), - ), - ) as _i6.PaymentUserState); - @override - _i8.Stream<_i6.PaymentUserState> get stream => (super.noSuchMethod( - Invocation.getter(#stream), - returnValue: _i8.Stream<_i6.PaymentUserState>.empty(), - ) as _i8.Stream<_i6.PaymentUserState>); - @override - bool get isClosed => (super.noSuchMethod( - Invocation.getter(#isClosed), - returnValue: false, - ) as bool); - @override - _i8.Future load() => (super.noSuchMethod( - Invocation.method( - #load, - [], - ), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), - ) as _i8.Future); - @override - void emit(_i6.PaymentUserState? state) => super.noSuchMethod( - Invocation.method( - #emit, - [state], - ), - returnValueForMissingStub: null, - ); - @override - void onChange(_i9.Change<_i6.PaymentUserState>? change) => super.noSuchMethod( - Invocation.method( - #onChange, - [change], - ), - returnValueForMissingStub: null, - ); - @override - void addError( - Object? error, [ - StackTrace? stackTrace, - ]) => - super.noSuchMethod( - Invocation.method( - #addError, - [ - error, - stackTrace, - ], - ), - returnValueForMissingStub: null, - ); - @override - void onError( - Object? error, - StackTrace? stackTrace, - ) => - super.noSuchMethod( - Invocation.method( - #onError, - [ - error, - stackTrace, - ], - ), - returnValueForMissingStub: null, - ); - @override - _i8.Future close() => (super.noSuchMethod( - Invocation.method( - #close, - [], - ), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), - ) as _i8.Future); -} - /// A class which mocks [WelcomeCubit]. /// /// See the documentation for Mockito's code generation for more information. diff --git a/test/widget/tpay_button_test.dart b/test/widget/tpay_button_test.dart deleted file mode 100644 index 088290ef9..000000000 --- a/test/widget/tpay_button_test.dart +++ /dev/null @@ -1,233 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; -import 'package:reaxit/api/exceptions.dart'; -import 'package:reaxit/blocs.dart'; -import 'package:reaxit/config.dart'; -import 'package:reaxit/models.dart'; -import 'package:reaxit/ui/widgets.dart'; - -import '../mocks.mocks.dart'; - -void main() { - group('TPayButton', () { - testWidgets('can be used to pay', (WidgetTester tester) async { - final payCompleter = Completer(); - - final paymentUserCubit = MockPaymentUserCubit(); - final streamController = StreamController.broadcast() - ..stream.listen((state) { - when(paymentUserCubit.state).thenReturn(state); - }) - ..add(const PaymentUserState.loading()) - ..add(const PaymentUserState.result( - user: PaymentUser('0.00', true, true), payments: [])); - - when(paymentUserCubit.load()).thenAnswer((_) => Future.value(null)); - when(paymentUserCubit.stream).thenAnswer((_) => streamController.stream); - - await tester.pumpWidget( - MaterialApp( - home: InheritedConfig( - config: Config.defaultConfig, - child: Scaffold( - body: BlocProvider.value( - value: paymentUserCubit, - child: TPayButton( - amount: '13.37', - successMessage: 'Nice!', - confirmationMessage: 'Are you sure?', - failureMessage: ':(', - onPay: () async => payCompleter.complete(), - ), - ), - ), - ), - ), - ); - - expect(payCompleter.isCompleted, false); - expect(find.text('THALIA PAY: €13.37'), findsOneWidget); - - await tester.tap(find.text('THALIA PAY: €13.37')); - await tester.pumpAndSettle(); - - expect(payCompleter.isCompleted, false); - expect(find.text('Are you sure?'), findsOneWidget); - expect(find.text('YES'), findsOneWidget); - - await tester.tap(find.text('YES')); - await tester.pumpAndSettle(); - - expect(payCompleter.isCompleted, true); - expect(find.text('Nice!'), findsOneWidget); - expect(find.text('Are you sure?'), findsNothing); - }); - - testWidgets('can be cancelled', (WidgetTester tester) async { - final payCompleter = Completer(); - - final paymentUserCubit = MockPaymentUserCubit(); - final streamController = StreamController.broadcast() - ..stream.listen((state) { - when(paymentUserCubit.state).thenReturn(state); - }) - ..add(const PaymentUserState.loading()) - ..add(const PaymentUserState.result( - user: PaymentUser('0.00', true, true), payments: [])); - - when(paymentUserCubit.load()).thenAnswer((_) => Future.value(null)); - when(paymentUserCubit.stream).thenAnswer((_) => streamController.stream); - - await tester.pumpWidget( - MaterialApp( - home: InheritedConfig( - config: Config.defaultConfig, - child: Scaffold( - body: BlocProvider.value( - value: paymentUserCubit, - child: TPayButton( - amount: '13.37', - successMessage: 'Nice!', - confirmationMessage: 'Are you sure?', - failureMessage: ':(', - onPay: () async => payCompleter.complete(), - ), - ), - ), - ), - ), - ); - - expect(payCompleter.isCompleted, false); - expect(find.text('THALIA PAY: €13.37'), findsOneWidget); - - await tester.tap(find.text('THALIA PAY: €13.37')); - await tester.pumpAndSettle(); - - expect(payCompleter.isCompleted, false); - expect(find.text('Are you sure?'), findsOneWidget); - expect(find.text('CANCEL'), findsOneWidget); - - await tester.tap(find.text('CANCEL')); - await tester.pumpAndSettle(); - expect(payCompleter.isCompleted, false); - expect(find.text('Nice!'), findsNothing); - expect(find.text('Are you sure?'), findsNothing); - }); - - testWidgets('displays snackbar on exception', (WidgetTester tester) async { - final paymentUserCubit = MockPaymentUserCubit(); - final streamController = StreamController.broadcast() - ..stream.listen((state) { - when(paymentUserCubit.state).thenReturn(state); - }) - ..add(const PaymentUserState.loading()) - ..add(const PaymentUserState.result( - user: PaymentUser('0.00', true, true), payments: [])); - - when(paymentUserCubit.load()).thenAnswer((_) => Future.value(null)); - when(paymentUserCubit.stream).thenAnswer((_) => streamController.stream); - - await tester.pumpWidget( - MaterialApp( - home: InheritedConfig( - config: Config.defaultConfig, - child: Scaffold( - body: BlocProvider.value( - value: paymentUserCubit, - child: TPayButton( - amount: '13.37', - successMessage: 'Nice!', - confirmationMessage: 'Are you sure?', - failureMessage: ':(', - onPay: () async { - throw ApiException.unknownError; - }, - ), - ), - ), - ), - ), - ); - - await tester.tap(find.text('THALIA PAY: €13.37')); - await tester.pumpAndSettle(); - await tester.tap(find.text('YES')); - await tester.pumpAndSettle(); - - expect(find.text(':('), findsOneWidget); - }); - - testWidgets('provides tooltips when disabled', (WidgetTester tester) async { - final paymentUserCubit = MockPaymentUserCubit(); - final streamController = StreamController.broadcast() - ..stream.listen((state) { - when(paymentUserCubit.state).thenReturn(state); - }) - ..add(const PaymentUserState.loading()) - ..add(const PaymentUserState.result( - user: PaymentUser('0.00', false, false), payments: [])); - - when(paymentUserCubit.load()).thenAnswer((_) => Future.value(null)); - when(paymentUserCubit.stream).thenAnswer((_) => streamController.stream); - - await tester.pumpWidget( - MaterialApp( - home: InheritedConfig( - config: Config.defaultConfig, - child: Scaffold( - body: BlocProvider.value( - value: paymentUserCubit, - child: TPayButton( - amount: '13.37', - successMessage: 'Nice!', - confirmationMessage: 'Are you sure?', - failureMessage: ':(', - onPay: () async { - throw ApiException.unknownError; - }, - ), - ), - ), - ), - ), - ); - - await tester.tap(find.text('THALIA PAY: €13.37')); - await tester.pumpAndSettle(); - expect(find.text('Confirm payment'), findsNothing); - expect( - find.byTooltip('You are not allowed to use Thalia Pay.'), - findsOneWidget, - ); - - streamController.add(const PaymentUserState.result( - user: PaymentUser('0.00', true, false), payments: [])); - await tester.pumpAndSettle(); - - await tester.tap(find.text('THALIA PAY: €13.37')); - await tester.pumpAndSettle(); - expect(find.text('Confirm payment'), findsNothing); - await tester.longPress(find.textContaining('THALIA PAY')); - await tester.pumpAndSettle(); - expect(find.textContaining('direct debit mandate'), findsOneWidget); - - streamController.add(const PaymentUserState.loading()); - await tester.pumpAndSettle(); - await tester.tap(find.text('THALIA PAY: €13.37')); - await tester.pumpAndSettle(); - expect(find.text('Confirm payment'), findsNothing); - - streamController.add(const PaymentUserState.failure( - message: 'An unknown error occurred.')); - await tester.pumpAndSettle(); - await tester.tap(find.text('THALIA PAY: €13.37')); - await tester.pumpAndSettle(); - expect(find.text('Confirm payment'), findsNothing); - }); - }); -}