From a81cd82eac3d7e3900abb2d721d22a2480343daa Mon Sep 17 00:00:00 2001 From: Mathias Mogensen Date: Mon, 24 Oct 2022 22:42:58 +0200 Subject: [PATCH 1/4] fix: unify otp fields into one --- lib/presentation/profile/profile_screen.dart | 628 +++++++++--------- .../settings/settings_screen.dart | 2 + .../shared_widgets/pin_input/pin_input.dart | 143 ++-- .../pin_input/pin_text_field.dart | 59 +- 4 files changed, 388 insertions(+), 444 deletions(-) diff --git a/lib/presentation/profile/profile_screen.dart b/lib/presentation/profile/profile_screen.dart index b60e007d..c722f193 100644 --- a/lib/presentation/profile/profile_screen.dart +++ b/lib/presentation/profile/profile_screen.dart @@ -6,7 +6,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:share_plus/share_plus.dart'; -import '../../application/auth/auth_bloc.dart'; import '../../application/user/profile/profile_bloc.dart'; import '../core/collaction_icons.dart'; import '../routes/app_routes.gr.dart'; @@ -36,372 +35,367 @@ class _UserProfilePageState extends State { return BlocProvider.value( value: BlocProvider.of(context), - child: BlocListener( - listener: (context, state) { - BlocProvider.of(context).add(GetUserProfile()); - }, - child: BlocBuilder( - builder: (context, state) { - bioController.value = - TextEditingValue(text: state.userProfile?.profile.bio ?? ''); + child: BlocBuilder( + builder: (context, state) { + bioController.value = + TextEditingValue(text: state.userProfile?.profile.bio ?? ''); - final scaffold = Scaffold( - extendBodyBehindAppBar: true, - backgroundColor: kAlmostTransparent, - floatingActionButtonLocation: - FloatingActionButtonLocation.miniEndTop, - floatingActionButton: Column( - children: [ - const SizedBox(height: 10), - ElevatedButton( - onPressed: share, - style: ElevatedButton.styleFrom( - shape: const CircleBorder(), - backgroundColor: kEnabledButtonColor, - ).merge( - ButtonStyle( - elevation: MaterialStateProperty.resolveWith( - (Set states) { - if (states.contains(MaterialState.pressed)) { - return 5; - } - return 4; - }, - ), + final scaffold = Scaffold( + extendBodyBehindAppBar: true, + backgroundColor: kAlmostTransparent, + floatingActionButtonLocation: + FloatingActionButtonLocation.miniEndTop, + floatingActionButton: Column( + children: [ + const SizedBox(height: 10), + ElevatedButton( + onPressed: share, + style: ElevatedButton.styleFrom( + shape: const CircleBorder(), + backgroundColor: kEnabledButtonColor, + ).merge( + ButtonStyle( + elevation: MaterialStateProperty.resolveWith( + (Set states) { + if (states.contains(MaterialState.pressed)) { + return 5; + } + return 4; + }, ), ), - child: const Padding( - padding: - EdgeInsets.symmetric(vertical: 8.0, horizontal: 4), - child: Icon(CollactionIcons.share), - ), ), - const SizedBox(height: 10), - ElevatedButton( - onPressed: () => context.router.push(const SettingsRoute()), - style: ElevatedButton.styleFrom( - foregroundColor: kPrimaryColor0, - backgroundColor: Colors.white, - shape: const CircleBorder(), - tapTargetSize: MaterialTapTargetSize.padded, - ).merge( - ButtonStyle( - elevation: MaterialStateProperty.resolveWith( - (Set states) { - if (states.contains(MaterialState.pressed)) { - return 5; - } - return 4; - }, - ), + child: const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0, horizontal: 4), + child: Icon(CollactionIcons.share), + ), + ), + const SizedBox(height: 10), + ElevatedButton( + onPressed: () => context.router.push(const SettingsRoute()), + style: ElevatedButton.styleFrom( + foregroundColor: kPrimaryColor0, + backgroundColor: Colors.white, + shape: const CircleBorder(), + tapTargetSize: MaterialTapTargetSize.padded, + ).merge( + ButtonStyle( + elevation: MaterialStateProperty.resolveWith( + (Set states) { + if (states.contains(MaterialState.pressed)) { + return 5; + } + return 4; + }, ), ), - child: const Padding( - padding: - EdgeInsets.symmetric(vertical: 8.0, horizontal: 4), - child: Icon( - CollactionIcons.settings, - color: kPrimaryColor300, - ), + ), + child: const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0, horizontal: 4), + child: Icon( + CollactionIcons.settings, + color: kPrimaryColor300, ), ), - ], - ), - body: SafeArea( - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric( - vertical: 30, - horizontal: 20, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Stack( - children: [ - Padding( - padding: const EdgeInsets.all(10.0), - child: ProfilePicture( - image: _image, - profileImage: state.userProfile?.profile - .avatar != - null - ? '${dotenv.get('BASE_STATIC_ENDPOINT_URL')}/${state.userProfile?.profile.avatar}' - : null, - maxRadius: 50, - ), + ), + ], + ), + body: SafeArea( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + vertical: 30, + horizontal: 20, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.all(10.0), + child: ProfilePicture( + image: _image, + profileImage: state + .userProfile?.profile.avatar != + null + ? '${dotenv.get('BASE_STATIC_ENDPOINT_URL')}/${state.userProfile?.profile.avatar}' + : null, + maxRadius: 50, ), - if (state.isEditing == true) ...[ - Positioned( - bottom: 0, - right: 0, - child: FloatingActionButton( - onPressed: () { - showModalBottomSheet( - context: context, - builder: (context) => PhotoSelector( - onSelected: (image) { - setState(() => _image = image); - context.router.pop("dialog"); - }, - ), - ); - }, - backgroundColor: kAccentColor, - mini: true, - child: const Icon(Icons.add), - ), + ), + if (state.isEditing == true) ...[ + Positioned( + bottom: 0, + right: 0, + child: FloatingActionButton( + onPressed: () { + showModalBottomSheet( + context: context, + builder: (context) => PhotoSelector( + onSelected: (image) { + setState( + () => _image = image, + ); + context.router.pop("dialog"); + }, + ), + ); + }, + backgroundColor: kAccentColor, + mini: true, + child: const Icon(Icons.add), ), - ] - ], - ), + ), + ] + ], ), - const SizedBox(height: 10), - Center( - child: Text( - state.userProfile?.profile.firstName ?? 'You', - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 22, - ), + ), + const SizedBox(height: 10), + Center( + child: Text( + state.userProfile?.profile.firstName ?? 'You', + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 22, ), ), - if (state.userProfile != null) ...[ - const SizedBox(height: 40), - const Text( - 'About me', - style: TextStyle( - fontWeight: FontWeight.w700, - fontSize: 11, - color: Color(0xFF666666), - ), - textAlign: TextAlign.left, + ), + if (state.userProfile != null) ...[ + const SizedBox(height: 40), + const Text( + 'About me', + style: TextStyle( + fontWeight: FontWeight.w700, + fontSize: 11, + color: Color(0xFF666666), ), - const SizedBox(height: 10), - if (state.isEditing == true) ...[ - Row( - children: [ - Expanded( - child: TextFormField( - controller: bioController, - cursorColor: kAccentColor, - maxLength: 150, - decoration: InputDecoration( - counterText: '', - fillColor: Colors.white, - border: OutlineInputBorder( - borderRadius: - BorderRadius.circular(10), - borderSide: BorderSide.none, - ), - enabledBorder: OutlineInputBorder( - borderRadius: - BorderRadius.circular(10), - borderSide: BorderSide.none, - ), - focusedBorder: OutlineInputBorder( - borderRadius: - BorderRadius.circular(10), - borderSide: BorderSide.none, - ), + textAlign: TextAlign.left, + ), + const SizedBox(height: 10), + if (state.isEditing == true) ...[ + Row( + children: [ + Expanded( + child: TextFormField( + controller: bioController, + cursorColor: kAccentColor, + maxLength: 150, + decoration: InputDecoration( + counterText: '', + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: + BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: + BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: + BorderRadius.circular(10), + borderSide: BorderSide.none, ), - minLines: 3, - maxLines: 5, - style: Theme.of(context) - .textTheme - .bodyText2! - .copyWith( - fontWeight: FontWeight.w300, - fontSize: 17, - color: kPrimaryColor400, - ), ), - ), - ], - ), - const SizedBox(height: 4), - Row( - children: [ - const SizedBox(width: 16), - Text( - 'Maximum 150 characters', + minLines: 3, + maxLines: 5, style: Theme.of(context) .textTheme .bodyText2! .copyWith( - fontSize: 12, - fontWeight: FontWeight.w400, - color: kPrimaryColor300, + fontWeight: FontWeight.w300, + fontSize: 17, + color: kPrimaryColor400, ), ), - ], - ) - ] else ...[ - Text( - state.userProfile?.profile.bio ?? '', - style: Theme.of(context) - .textTheme - .bodyText2! - .copyWith( - fontWeight: FontWeight.w300, - fontSize: 17, - color: kPrimaryColor400, - ), + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + const SizedBox(width: 16), + Text( + 'Maximum 150 characters', + style: Theme.of(context) + .textTheme + .bodyText2! + .copyWith( + fontSize: 12, + fontWeight: FontWeight.w400, + color: kPrimaryColor300, + ), + ), + ], + ) + ] else ...[ + Text( + state.userProfile?.profile.bio ?? '', + style: Theme.of(context) + .textTheme + .bodyText2! + .copyWith( + fontWeight: FontWeight.w300, + fontSize: 17, + color: kPrimaryColor400, + ), + ), + ], + const SizedBox(height: 40), + RichText( + text: TextSpan( + text: 'Joined ', + style: const TextStyle( + color: kPrimaryColor200, + fontWeight: FontWeight.w700, + fontSize: 11, ), - ], - const SizedBox(height: 40), - RichText( - text: TextSpan( - text: 'Joined ', - style: const TextStyle( - color: kPrimaryColor200, - fontWeight: FontWeight.w700, - fontSize: 11, + children: [ + TextSpan( + text: state + .userProfile?.user.formattedJoinDate, + style: const TextStyle( + color: kPrimaryColor300, + fontWeight: FontWeight.w700, + fontSize: 11, + ), + ) + ], + ), + ), + const SizedBox(height: 40), + TextButton( + key: const Key('save_edit_button'), + style: ButtonStyle( + overlayColor: MaterialStateColor.resolveWith( + (states) => state.isEditing == true + ? Colors.white.withOpacity(0.1) + : kAccentColor.withOpacity(0.1), + ), + backgroundColor: state.isEditing == true + ? MaterialStateProperty.all( + kAccentColor, + ) + : null, + minimumSize: MaterialStateProperty.all( + const Size(double.infinity * 0.75, 52), + ), + shape: MaterialStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(200), + side: const BorderSide( + color: kAccentColor, + ), ), - children: [ - TextSpan( - text: state - .userProfile?.user.formattedJoinDate, - style: const TextStyle( - color: kPrimaryColor300, - fontWeight: FontWeight.w700, - fontSize: 11, - ), - ) - ], ), ), - const SizedBox(height: 40), + onPressed: () { + if (state.isEditing == true) { + /// TODO: Implement save profile image + BlocProvider.of(context).add( + SaveProfile( + bio: bioController.text, + image: _image, + ), + ); + } else { + context + .read() + .add(EditProfile()); + } + }, + child: Text( + state.isEditing == true + ? 'Save changes' + : 'Edit profile', + style: TextStyle( + color: state.isEditing == true + ? Colors.white + : kAccentColor, + fontWeight: FontWeight.w700, + ), + ), + ), + if (state.isEditing == true) ...[ + const SizedBox(height: 10), TextButton( - key: const Key('save_edit_button'), + key: const Key('cancel_edit_button'), style: ButtonStyle( - overlayColor: MaterialStateColor.resolveWith( - (states) => state.isEditing == true - ? Colors.white.withOpacity(0.1) - : kAccentColor.withOpacity(0.1), - ), - backgroundColor: state.isEditing == true - ? MaterialStateProperty.all(kAccentColor) - : null, minimumSize: MaterialStateProperty.all( const Size(double.infinity * 0.75, 52), ), shape: MaterialStateProperty.all( RoundedRectangleBorder( borderRadius: BorderRadius.circular(200), - side: const BorderSide( - color: kAccentColor, - ), ), ), ), onPressed: () { - if (state.isEditing == true) { - /// TODO: Implement save profile image - BlocProvider.of(context).add( - SaveProfile( - bio: bioController.text, - image: _image, - ), - ); - } else { - context - .read() - .add(EditProfile()); - } + BlocProvider.of(context) + .add(CancelEditProfile()); + + _image = null; + bioController.value = TextEditingValue( + text: state.userProfile?.profile.bio ?? '', + ); }, - child: Text( - state.isEditing == true - ? 'Save changes' - : 'Edit profile', + child: const Text( + 'Cancel', style: TextStyle( - color: state.isEditing == true - ? Colors.white - : kAccentColor, + color: kAccentColor, fontWeight: FontWeight.w700, ), ), ), - if (state.isEditing == true) ...[ - const SizedBox(height: 10), - TextButton( - key: const Key('cancel_edit_button'), - style: ButtonStyle( - minimumSize: MaterialStateProperty.all( - const Size(double.infinity * 0.75, 52), - ), - shape: MaterialStateProperty.all( - RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(200), - ), - ), - ), - onPressed: () { - BlocProvider.of(context) - .add(CancelEditProfile()); - - _image = null; - bioController.value = TextEditingValue( - text: - state.userProfile?.profile.bio ?? '', - ); - }, - child: const Text( - 'Cancel', - style: TextStyle( - color: kAccentColor, - fontWeight: FontWeight.w700, - ), - ), - ), - ], - ] else ...[ - // TODO only for MVP (remove later) - const SizedBox(height: 40), - PillButton( - text: 'Create account or sign in', - onTap: () => context.router - .push(const AuthRoute()) - .then((_) { - // Refresh profile - context - .read() - .add(GetUserProfile()); - }), - ), ], - const SizedBox(height: 20), + ] else ...[ + // TODO only for MVP (remove later) + const SizedBox(height: 40), + PillButton( + text: 'Sign in', + onTap: () => context.router + .push(const AuthRoute()) + .then((_) { + // Refresh profile + context + .read() + .add(GetUserProfile()); + }), + ), ], - ), + const SizedBox(height: 20), + ], ), - UserProfileTab(user: state.userProfile?.user), - ], - ), + ), + UserProfileTab(user: state.userProfile?.user), + ], ), ), - ); + ), + ); - return BlocListener( - listener: (context, state) { - if (state.wasProfilePictureUpdated == true) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text("Profile picture will be reviewed!"), - duration: Duration(seconds: 5), - ), - ); - } - }, - child: scaffold, - ); - }, - ), + return BlocListener( + listener: (context, state) { + if (state.wasProfilePictureUpdated == true) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Profile picture will be reviewed!"), + duration: Duration(seconds: 5), + ), + ); + } + }, + child: scaffold, + ); + }, ), ); } diff --git a/lib/presentation/settings/settings_screen.dart b/lib/presentation/settings/settings_screen.dart index 9be1f7d1..b0343df0 100644 --- a/lib/presentation/settings/settings_screen.dart +++ b/lib/presentation/settings/settings_screen.dart @@ -122,6 +122,8 @@ class SettingsPage extends StatelessWidget { onTap: () async { BlocProvider.of(context) .add(const AuthEvent.signedOut()); + BlocProvider.of(context) + .add(GetUserProfile()); await context.router.pop(); }, ), diff --git a/lib/presentation/shared_widgets/pin_input/pin_input.dart b/lib/presentation/shared_widgets/pin_input/pin_input.dart index 5067c0b5..5ccd4698 100644 --- a/lib/presentation/shared_widgets/pin_input/pin_input.dart +++ b/lib/presentation/shared_widgets/pin_input/pin_input.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'pin_text_field.dart'; +import '../../themes/constants.dart'; class PinInput extends StatefulWidget { final int pinLength; @@ -22,119 +22,72 @@ class PinInput extends StatefulWidget { class PinInputState extends State { static const int backspace = 0x100000008; - late List size; - late List focusNodes; - late List controllers; + late FocusNode focusNode; + late TextEditingController controller; @override void initState() { super.initState(); - - size = List.generate(widget.pinLength, (i) => i); - focusNodes = size.map((_) => FocusNode()).toList(growable: false); - controllers = - size.map((_) => TextEditingController()).toList(growable: false); + focusNode = FocusNode(); + controller = TextEditingController(); } @override Widget build(BuildContext context) { - return KeyboardListener( - focusNode: FocusNode(), - onKeyEvent: (keyEvent) { - if (keyEvent.logicalKey.keyId == backspace) { - _onBackspace(); - } - }, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: size - .map( - (index) => PinTextField( - key: ValueKey('pin_input_$index'), - controller: controllers[index], - focusNode: focusNodes[index], - onChanged: (value) { - if (value.isNotEmpty) { - _onChanged(value, index); - } - }, - readOnly: widget.readOnly, - onPaste: _onPaste, + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: TextFormField( + controller: controller, + focusNode: focusNode, + textAlign: TextAlign.center, + maxLength: 6, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp('[0-9]')), + ], + style: const TextStyle(fontSize: 28, letterSpacing: 4), + cursorColor: kAccentColor, + decoration: InputDecoration( + contentPadding: + EdgeInsets.all(MediaQuery.of(context).size.width * 0.02), + counterText: "", + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(20.0), + borderSide: + const BorderSide(width: 0, color: Colors.transparent), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(20.0), + borderSide: const BorderSide(color: kAccentColor), ), - ) - .toList(growable: false), - ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(20.0), + borderSide: const BorderSide(color: kAccentColor), + ), + ), + onChanged: (value) { + if (value.length == widget.pinLength) { + widget.submit(value); + } + }, + ), + ), + ], ); } - void _onChanged(String value, int index) { - if (_pin.length == widget.pinLength) { - widget.submit(_pin); - } - - FocusNode? next; - FocusNode? previous; - - if (index != 0) { - previous = focusNodes[index - 1]; - } - - if (index != size.length - 1) { - next = focusNodes[index + 1]; - } - - if (value.isNotEmpty && next != null) { - next.requestFocus(); - return; - } - - if (value.isEmpty && previous != null) { - previous.requestFocus(); - return; - } - } - void autoComplete(String code) { if (code.length == widget.pinLength) { - final digits = code.split(""); - digits.asMap().forEach((index, value) => controllers[index].text = value); - - widget.submit(_pin); - } - } - - Future _onPaste() async { - final pin = (await Clipboard.getData("text/plain"))?.text ?? ''; - - // Validate clipboard data - // * Should be same length as PIN - // * Should all be digits - if (pin.length == widget.pinLength && int.tryParse(pin) != null) { - autoComplete(pin); - } - } - - void _onBackspace() { - final focusNodeIndex = focusNodes.indexWhere((element) => element.hasFocus); - final controller = controllers[focusNodeIndex]; - - if (controller.text.isEmpty) { - _onChanged("", focusNodeIndex); + widget.submit(code); } } - String get _pin => controllers.map((c) => c.text).join(); - @override void dispose() { - for (final node in focusNodes) { - node.dispose(); - } - - for (final controller in controllers) { - controller.dispose(); - } - + focusNode.dispose(); + controller.dispose(); super.dispose(); } } diff --git a/lib/presentation/shared_widgets/pin_input/pin_text_field.dart b/lib/presentation/shared_widgets/pin_input/pin_text_field.dart index f4ef0bfc..42be3d7f 100644 --- a/lib/presentation/shared_widgets/pin_input/pin_text_field.dart +++ b/lib/presentation/shared_widgets/pin_input/pin_text_field.dart @@ -7,7 +7,6 @@ class PinTextField extends StatelessWidget { final TextEditingController controller; final FocusNode focusNode; final ValueChanged onChanged; - final VoidCallback? onPaste; const PinTextField({ super.key, @@ -15,7 +14,6 @@ class PinTextField extends StatelessWidget { required this.controller, required this.focusNode, required this.onChanged, - this.onPaste, }); @override @@ -28,39 +26,36 @@ class PinTextField extends StatelessWidget { height: MediaQuery.of(context).size.width * 0.12, width: MediaQuery.of(context).size.width * 0.12, child: GestureDetector( - onLongPress: onPaste, onTap: () => focusNode.requestFocus(), - child: AbsorbPointer( - child: TextFormField( - readOnly: readOnly, - controller: controller, - textAlignVertical: TextAlignVertical.center, - textAlign: TextAlign.center, - showCursor: false, - keyboardType: TextInputType.number, - style: const TextStyle(fontSize: 28), - maxLength: 1, - decoration: InputDecoration( - contentPadding: - EdgeInsets.all(MediaQuery.of(context).size.width * 0.02), - counterText: "", - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(20.0), - borderSide: - const BorderSide(width: 0, color: Colors.transparent), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(20.0), - borderSide: const BorderSide(color: kAccentColor), - ), - errorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(20.0), - borderSide: const BorderSide(color: kAccentColor), - ), + child: TextFormField( + readOnly: readOnly, + controller: controller, + textAlignVertical: TextAlignVertical.center, + textAlign: TextAlign.center, + showCursor: false, + keyboardType: TextInputType.number, + style: const TextStyle(fontSize: 28), + maxLength: 6, + decoration: InputDecoration( + contentPadding: + EdgeInsets.all(MediaQuery.of(context).size.width * 0.02), + counterText: "", + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(20.0), + borderSide: + const BorderSide(width: 0, color: Colors.transparent), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(20.0), + borderSide: const BorderSide(color: kAccentColor), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(20.0), + borderSide: const BorderSide(color: kAccentColor), ), - focusNode: focusNode, - onChanged: onChanged, ), + focusNode: focusNode, + onChanged: onChanged, ), ), ), From bcc8e3187a73b59d7f1070e911119752597a1d6e Mon Sep 17 00:00:00 2001 From: Mathias Mogensen Date: Tue, 25 Oct 2022 15:15:05 +0200 Subject: [PATCH 2/4] fix: crowdaction chip + browse crowdactions --- .../crowdaction_getter_bloc.dart | 62 +++++---- .../crowdaction_getter_event.dart | 5 +- .../crowdaction_getter_state.dart | 15 ++- .../crowdaction_participants_bloc.dart | 3 +- lib/domain/core/page_info.dart | 15 +++ lib/domain/crowdaction/crowdaction.dart | 12 +- .../crowdaction/i_crowdaction_repository.dart | 6 +- .../crowdaction/paginated_crowdactions.dart | 16 +++ .../paginated_participations.dart | 14 +- lib/infrastructure/core/page_info_dto.dart | 28 ++++ .../crowdaction/crowdaction_repository.dart | 38 ++++-- .../paginated_crowdactions_dto.dart | 26 ++++ .../paginated_participations_dto.dart | 26 +--- .../participation_repository.dart | 2 + .../crowdaction_browse_screen.dart | 126 ++++++++++++------ .../home/widgets/crowdaction_carousel.dart | 23 ++-- .../home/widgets/current_upcoming_layout.dart | 7 +- .../micro_crowdaction_card.dart | 12 +- .../crowdaction_getter_bloc_test.dart | 89 ------------- test/test_utilities.dart | 4 - 20 files changed, 286 insertions(+), 243 deletions(-) create mode 100644 lib/domain/core/page_info.dart create mode 100644 lib/domain/crowdaction/paginated_crowdactions.dart create mode 100644 lib/infrastructure/core/page_info_dto.dart create mode 100644 lib/infrastructure/crowdaction/paginated_crowdactions_dto.dart delete mode 100644 test/application/crowdaction/crowdaction_getter_bloc_test.dart diff --git a/lib/application/crowdaction/crowdaction_getter/crowdaction_getter_bloc.dart b/lib/application/crowdaction/crowdaction_getter/crowdaction_getter_bloc.dart index 8cf6bbb1..ce015e84 100644 --- a/lib/application/crowdaction/crowdaction_getter/crowdaction_getter_bloc.dart +++ b/lib/application/crowdaction/crowdaction_getter/crowdaction_getter_bloc.dart @@ -1,9 +1,8 @@ -/// TODO: Remove all old code related to GraphQL!!! import 'package:bloc/bloc.dart'; -import 'package:dartz/dartz.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:injectable/injectable.dart'; +import '../../../domain/core/page_info.dart'; import '../../../domain/crowdaction/crowdaction.dart'; import '../../../domain/crowdaction/crowdaction_failures.dart'; import '../../../domain/crowdaction/i_crowdaction_repository.dart'; @@ -19,36 +18,45 @@ class CrowdActionGetterBloc CrowdActionGetterBloc(this._crowdActionRepository) : super(const CrowdActionGetterState.initial()) { - on( - (event, emit) async { - await event.map( - getMore: (event) async { - emit(const CrowdActionGetterState.fetchingCrowdActions()); - try { - Either> response; - if (event.amount != null && event.amount! > 0) { - response = await _crowdActionRepository.getCrowdActions( - amount: event.amount!, + on((event, emit) async { + await event.map( + init: (_) async { + emit(const CrowdActionGetterState.loading()); + emit(const CrowdActionGetterState.initial()); + }, + getCrowdActions: (event) async { + emit(const CrowdActionGetterState.loading()); + + final paginatedCrowdActionsOrFailure = + await _crowdActionRepository.getCrowdActions( + pageNumber: event.pageNumber, + ); + + paginatedCrowdActionsOrFailure.fold( + (failure) => emit( + CrowdActionGetterState.failure(failure), + ), + (paginatedCrowdActions) { + if (paginatedCrowdActions.pageInfo.page == + paginatedCrowdActions.pageInfo.totalPages) { + emit( + CrowdActionGetterState.finished( + crowdActions: paginatedCrowdActions.crowdActions, + ), ); - } else { - response = await _crowdActionRepository.getCrowdActions(); + return; } emit( - response.fold( - (failure) => const CrowdActionGetterState.noCrowdActions(), - (crowdActions) => - CrowdActionGetterState.fetched(crowdActions), + CrowdActionGetterState.success( + crowdActions: paginatedCrowdActions.crowdActions, + pageInfo: paginatedCrowdActions.pageInfo, ), ); - } catch (e) { - emit( - const CrowdActionGetterState.noCrowdActions(), - ); // TODO: Consider implementing error state - } - }, - ); - }, - ); + }, + ); + }, + ); + }); } } diff --git a/lib/application/crowdaction/crowdaction_getter/crowdaction_getter_event.dart b/lib/application/crowdaction/crowdaction_getter/crowdaction_getter_event.dart index 90b71055..dfc64464 100644 --- a/lib/application/crowdaction/crowdaction_getter/crowdaction_getter_event.dart +++ b/lib/application/crowdaction/crowdaction_getter/crowdaction_getter_event.dart @@ -2,5 +2,8 @@ part of 'crowdaction_getter_bloc.dart'; @freezed class CrowdActionGetterEvent with _$CrowdActionGetterEvent { - const factory CrowdActionGetterEvent.getMore(int? amount) = _GetMore; + const factory CrowdActionGetterEvent.init() = _Init; + const factory CrowdActionGetterEvent.getCrowdActions({ + @Default(1) int pageNumber, + }) = _GetCrowdActions; } diff --git a/lib/application/crowdaction/crowdaction_getter/crowdaction_getter_state.dart b/lib/application/crowdaction/crowdaction_getter/crowdaction_getter_state.dart index 99801977..3818019d 100644 --- a/lib/application/crowdaction/crowdaction_getter/crowdaction_getter_state.dart +++ b/lib/application/crowdaction/crowdaction_getter/crowdaction_getter_state.dart @@ -4,11 +4,16 @@ part of 'crowdaction_getter_bloc.dart'; class CrowdActionGetterState with _$CrowdActionGetterState { const factory CrowdActionGetterState.initial() = _Initial; - const factory CrowdActionGetterState.fetchingCrowdActions() = - _FetchingCrowdActions; + const factory CrowdActionGetterState.loading() = _Loading; - const factory CrowdActionGetterState.noCrowdActions() = _NoCrowdActions; + const factory CrowdActionGetterState.success({ + required List crowdActions, + required PageInfo pageInfo, + }) = _Success; - const factory CrowdActionGetterState.fetched(List crowdActions) = - _Fetched; + const factory CrowdActionGetterState.finished({ + required List crowdActions, + }) = _Finished; + const factory CrowdActionGetterState.failure(CrowdActionFailure failure) = + _Failure; } diff --git a/lib/application/crowdaction/crowdaction_participants/crowdaction_participants_bloc.dart b/lib/application/crowdaction/crowdaction_participants/crowdaction_participants_bloc.dart index bc73c08d..9c923cee 100644 --- a/lib/application/crowdaction/crowdaction_participants/crowdaction_participants_bloc.dart +++ b/lib/application/crowdaction/crowdaction_participants/crowdaction_participants_bloc.dart @@ -1,11 +1,12 @@ import 'package:bloc/bloc.dart'; import 'package:collaction_app/domain/participation/i_participation_repository.dart'; -import 'package:collaction_app/domain/participation/paginated_participations.dart'; import 'package:collaction_app/domain/participation/participation.dart'; import 'package:collaction_app/domain/participation/participation_failures.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:injectable/injectable.dart'; +import '../../../domain/core/page_info.dart'; + part 'crowdaction_participants_bloc.freezed.dart'; part 'crowdaction_participants_event.dart'; part 'crowdaction_participants_state.dart'; diff --git a/lib/domain/core/page_info.dart b/lib/domain/core/page_info.dart new file mode 100644 index 00000000..f5dd705d --- /dev/null +++ b/lib/domain/core/page_info.dart @@ -0,0 +1,15 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'page_info.freezed.dart'; + +@freezed +class PageInfo with _$PageInfo { + const PageInfo._(); + + const factory PageInfo({ + required int page, + required int pageSize, + required int totalPages, + required int totalItems, + }) = _PageInfo; +} diff --git a/lib/domain/crowdaction/crowdaction.dart b/lib/domain/crowdaction/crowdaction.dart index 9384cbfe..90a2f666 100644 --- a/lib/domain/crowdaction/crowdaction.dart +++ b/lib/domain/crowdaction/crowdaction.dart @@ -39,7 +39,17 @@ class CrowdAction with _$CrowdAction { } bool get isOpen => joinStatus == JoinStatus.open; - bool get isEnded => joinStatus == JoinStatus.closed; + bool get isRunning => status == Status.started; + bool get isClosed => status == Status.ended; + bool get isWaiting => status == Status.waiting; + + String get statusChipLabel => isOpen + ? 'Now open' + : isRunning + ? 'Currently running' + : isWaiting + ? 'Starting soon' + : 'Finished'; } @freezed diff --git a/lib/domain/crowdaction/i_crowdaction_repository.dart b/lib/domain/crowdaction/i_crowdaction_repository.dart index b44b76fc..a6de220b 100644 --- a/lib/domain/crowdaction/i_crowdaction_repository.dart +++ b/lib/domain/crowdaction/i_crowdaction_repository.dart @@ -1,3 +1,4 @@ +import 'package:collaction_app/domain/crowdaction/paginated_crowdactions.dart'; import 'package:dartz/dartz.dart'; import 'crowdaction.dart'; @@ -9,9 +10,10 @@ abstract class ICrowdActionRepository { String id, ); - Future>> getCrowdActions({ - int amount = 0, + Future> getCrowdActions({ + int pageNumber = 1, }); + Future>> getSpotlightCrowdActions(); diff --git a/lib/domain/crowdaction/paginated_crowdactions.dart b/lib/domain/crowdaction/paginated_crowdactions.dart new file mode 100644 index 00000000..765fdfc6 --- /dev/null +++ b/lib/domain/crowdaction/paginated_crowdactions.dart @@ -0,0 +1,16 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../core/page_info.dart'; +import 'crowdaction.dart'; + +part 'paginated_crowdactions.freezed.dart'; + +@freezed +class PaginatedCrowdActions with _$PaginatedCrowdActions { + const PaginatedCrowdActions._(); + + const factory PaginatedCrowdActions({ + required List crowdActions, + required PageInfo pageInfo, + }) = _PaginatedCrowdActions; +} diff --git a/lib/domain/participation/paginated_participations.dart b/lib/domain/participation/paginated_participations.dart index 51d2f9a7..382c237d 100644 --- a/lib/domain/participation/paginated_participations.dart +++ b/lib/domain/participation/paginated_participations.dart @@ -1,19 +1,9 @@ import 'package:collaction_app/domain/participation/participation.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; -part 'paginated_participations.freezed.dart'; - -@freezed -class PageInfo with _$PageInfo { - const PageInfo._(); +import '../core/page_info.dart'; - const factory PageInfo({ - required int page, - required int pageSize, - required int totalPages, - required int totalItems, - }) = _PageInfo; -} +part 'paginated_participations.freezed.dart'; @freezed class PaginatedParticipations with _$PaginatedParticipations { diff --git a/lib/infrastructure/core/page_info_dto.dart b/lib/infrastructure/core/page_info_dto.dart new file mode 100644 index 00000000..90c2be6e --- /dev/null +++ b/lib/infrastructure/core/page_info_dto.dart @@ -0,0 +1,28 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../domain/core/page_info.dart'; + +part 'page_info_dto.g.dart'; +part 'page_info_dto.freezed.dart'; + +@freezed +class PageInfoDto with _$PageInfoDto { + const PageInfoDto._(); + + const factory PageInfoDto({ + required int page, + required int pageSize, + required int totalPages, + required int totalItems, + }) = _PageInfoDto; + + factory PageInfoDto.fromJson(Map json) => + _$PageInfoDtoFromJson(json); + + PageInfo toDomain() => PageInfo( + page: page, + pageSize: pageSize, + totalPages: totalPages, + totalItems: totalItems, + ); +} diff --git a/lib/infrastructure/crowdaction/crowdaction_repository.dart b/lib/infrastructure/crowdaction/crowdaction_repository.dart index a789e450..020c93f4 100644 --- a/lib/infrastructure/crowdaction/crowdaction_repository.dart +++ b/lib/infrastructure/crowdaction/crowdaction_repository.dart @@ -1,6 +1,8 @@ import 'dart:convert'; import 'package:collaction_app/domain/core/i_settings_repository.dart'; +import 'package:collaction_app/domain/crowdaction/paginated_crowdactions.dart'; +import 'package:collaction_app/infrastructure/crowdaction/paginated_crowdactions_dto.dart'; import 'package:dartz/dartz.dart'; import 'package:http/http.dart' as http; import 'package:injectable/injectable.dart'; @@ -11,6 +13,7 @@ import '../../domain/crowdaction/crowdaction.dart'; import '../../domain/crowdaction/crowdaction_failures.dart'; import '../../domain/crowdaction/crowdaction_status.dart'; import '../../domain/crowdaction/i_crowdaction_repository.dart'; +import '../core/page_info_dto.dart'; import 'crowdaction_dto.dart'; import 'crowdaction_status_dto.dart'; @@ -51,30 +54,37 @@ class CrowdActionRepository implements ICrowdActionRepository { } @override - Future>> getCrowdActions({ - int amount = 0, + Future> getCrowdActions({ + int pageNumber = 1, }) async { try { final response = await _client.get( Uri.parse( - '${await _settingsRepository.baseApiEndpointUrl}/v1/crowdactions', + '${await _settingsRepository.baseApiEndpointUrl}/v1/crowdactions?page=$pageNumber', ), - headers: {'Content-Type': 'application/json'}, ); + if (response.statusCode == 200) { - final responseBody = jsonDecode(response.body); + final json = jsonDecode(response.body) as Map; + final itemsJson = json['items'] as List; + final pageInfoJson = json['pageInfo'] as Map; + + final crowdActions = itemsJson + .map( + (item) => CrowdActionDto.fromJson(item as Map), + ) + .toList(); + + final pageInfo = PageInfoDto.fromJson(pageInfoJson); + return right( - responseBody['items'] - .map( - (json) => CrowdActionDto.fromJson(json as Map) - .toDomain(), - ) - .toList() as List, + PaginatedCrowdActionsDto( + crowdActions: crowdActions, + pageInfo: pageInfo, + ).toDomain(), ); } else { - return left( - const CrowdActionFailure.serverError(), - ); + return left(const CrowdActionFailure.serverError()); } } catch (error) { return left(const CrowdActionFailure.networkRequestFailed()); diff --git a/lib/infrastructure/crowdaction/paginated_crowdactions_dto.dart b/lib/infrastructure/crowdaction/paginated_crowdactions_dto.dart new file mode 100644 index 00000000..b5b0187d --- /dev/null +++ b/lib/infrastructure/crowdaction/paginated_crowdactions_dto.dart @@ -0,0 +1,26 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../domain/crowdaction/paginated_crowdactions.dart'; +import '../core/page_info_dto.dart'; +import 'crowdaction_dto.dart'; + +part 'paginated_crowdactions_dto.g.dart'; +part 'paginated_crowdactions_dto.freezed.dart'; + +@freezed +class PaginatedCrowdActionsDto with _$PaginatedCrowdActionsDto { + const PaginatedCrowdActionsDto._(); + + const factory PaginatedCrowdActionsDto({ + required List crowdActions, + required PageInfoDto pageInfo, + }) = _PaginatedCrowdActionsDto; + + factory PaginatedCrowdActionsDto.fromJson(Map json) => + _$PaginatedCrowdActionsDtoFromJson(json); + + PaginatedCrowdActions toDomain() => PaginatedCrowdActions( + crowdActions: crowdActions.map((e) => e.toDomain()).toList(), + pageInfo: pageInfo.toDomain(), + ); +} diff --git a/lib/infrastructure/participation/paginated_participations_dto.dart b/lib/infrastructure/participation/paginated_participations_dto.dart index aee1eec1..5af78681 100644 --- a/lib/infrastructure/participation/paginated_participations_dto.dart +++ b/lib/infrastructure/participation/paginated_participations_dto.dart @@ -2,30 +2,10 @@ import 'package:collaction_app/domain/participation/paginated_participations.dar import 'package:collaction_app/infrastructure/participation/participation_dto.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; -part 'paginated_participations_dto.freezed.dart'; -part 'paginated_participations_dto.g.dart'; - -@freezed -class PageInfoDto with _$PageInfoDto { - const PageInfoDto._(); - - const factory PageInfoDto({ - required int page, - required int pageSize, - required int totalPages, - required int totalItems, - }) = _PageInfoDto; +import '../core/page_info_dto.dart'; - factory PageInfoDto.fromJson(Map json) => - _$PageInfoDtoFromJson(json); - - PageInfo toDomain() => PageInfo( - page: page, - pageSize: pageSize, - totalPages: totalPages, - totalItems: totalItems, - ); -} +part 'paginated_participations_dto.g.dart'; +part 'paginated_participations_dto.freezed.dart'; @freezed class PaginatedParticipationsDto with _$PaginatedParticipationsDto { diff --git a/lib/infrastructure/participation/participation_repository.dart b/lib/infrastructure/participation/participation_repository.dart index 75737c41..3a91a4fd 100644 --- a/lib/infrastructure/participation/participation_repository.dart +++ b/lib/infrastructure/participation/participation_repository.dart @@ -13,6 +13,8 @@ import 'package:dartz/dartz.dart'; import 'package:http/http.dart' as http; import 'package:injectable/injectable.dart'; +import '../core/page_info_dto.dart'; + @LazySingleton(as: IParticipationRepository) class ParticipationRepository implements IParticipationRepository { final http.Client client; diff --git a/lib/presentation/crowdaction/crowdaction_browse/crowdaction_browse_screen.dart b/lib/presentation/crowdaction/crowdaction_browse/crowdaction_browse_screen.dart index de5a5c46..d5c10851 100644 --- a/lib/presentation/crowdaction/crowdaction_browse/crowdaction_browse_screen.dart +++ b/lib/presentation/crowdaction/crowdaction_browse/crowdaction_browse_screen.dart @@ -1,66 +1,104 @@ +import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import '../../../application/crowdaction/crowdaction_getter/crowdaction_getter_bloc.dart'; +import '../../../domain/crowdaction/crowdaction.dart'; import '../../../infrastructure/core/injection.dart'; -import '../../shared_widgets/centered_loading_indicator.dart'; -import '../../shared_widgets/custom_app_bars/custom_appbar.dart'; import '../../shared_widgets/micro_crowdaction_card.dart'; import '../../themes/constants.dart'; -/// Route for the user to browse available Collactions. class CrowdActionBrowsePage extends StatelessWidget { - const CrowdActionBrowsePage({super.key}); + CrowdActionBrowsePage({super.key}); + + final PagingController pagingController = + PagingController(firstPageKey: 1); @override Widget build(BuildContext context) { return BlocProvider( create: (context) => getIt() - ..add(const CrowdActionGetterEvent.getMore(null)), - child: const Scaffold( - appBar: CustomAppBar(title: 'Browse CrowdActions'), - body: _CrowdActionBrowseView(), - ), - ); - } -} + ..add(const CrowdActionGetterEvent.init()), + child: BlocListener( + listener: (context, state) { + state.map( + initial: (_) { + pagingController.addPageRequestListener((pageKey) { + BlocProvider.of(context).add( + CrowdActionGetterEvent.getCrowdActions( + pageNumber: pageKey, + ), + ); + }); -class _CrowdActionBrowseView extends StatelessWidget { - const _CrowdActionBrowseView(); - - @override - Widget build(BuildContext context) { - return RefreshIndicator( - onRefresh: () async => Future.delayed( - const Duration(seconds: 1), - () => context.read().add( - const CrowdActionGetterEvent.getMore(null), + BlocProvider.of(context).add( + const CrowdActionGetterEvent.getCrowdActions( + pageNumber: 1, + ), + ); + }, + loading: (_) {}, + success: (state) { + pagingController.appendPage( + state.crowdActions, + state.pageInfo.page + 1, + ); + }, + finished: (state) { + pagingController.appendLastPage(state.crowdActions); + }, + failure: (_) {}, + ); + }, + child: Scaffold( + appBar: AppBar( + elevation: 0, + backgroundColor: Colors.transparent, + leading: IconButton( + icon: const Icon( + Icons.chevron_left, + color: kPrimaryColor200, + ), + onPressed: () => context.router.pop(), ), - ), - color: kAccentColor, - child: BlocBuilder( - builder: (context, state) => state.maybeMap( - initial: (_) => const CenteredLoadingIndicator(), - fetchingCrowdActions: (_) => const CenteredLoadingIndicator(), - noCrowdActions: (_) => Column( - mainAxisAlignment: MainAxisAlignment.center, - children: const [ - Center( - child: Text( - 'No CrowdActions at the moment...', - style: TextStyle(fontSize: 16.0), + title: const Text( + "Browse CrowdActions", + style: TextStyle( + fontWeight: FontWeight.bold, + color: kPrimaryColor400, + ), + ), + ), + body: RefreshIndicator( + color: kAccentColor, + onRefresh: () async { + pagingController.refresh(); + }, + child: PagedListView.separated( + pagingController: pagingController, + separatorBuilder: (context, index) => const SizedBox( + height: 16, + ), + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, crowdAction, index) => + MicroCrowdActionCard( + crowdAction, ), + firstPageProgressIndicatorBuilder: (context) => const Center( + child: CircularProgressIndicator(color: kAccentColor), + ), + newPageProgressIndicatorBuilder: (context) => const Center( + child: CircularProgressIndicator(color: kAccentColor), + ), + firstPageErrorIndicatorBuilder: (context) => const Text( + 'Something went wrong, try to refresh by dragging down', + ), + noItemsFoundIndicatorBuilder: (context) => + const Text('No crowdactions yet!'), ), - ], + ), ), - fetched: (crowdActions) { - return ListView.builder( - itemCount: crowdActions.crowdActions.length, - itemBuilder: (context, index) => - MicroCrowdActionCard(crowdActions.crowdActions[index]), - ); - }, - orElse: () => const SizedBox(), ), ), ); diff --git a/lib/presentation/home/widgets/crowdaction_carousel.dart b/lib/presentation/home/widgets/crowdaction_carousel.dart index 699c3189..8eb6e112 100644 --- a/lib/presentation/home/widgets/crowdaction_carousel.dart +++ b/lib/presentation/home/widgets/crowdaction_carousel.dart @@ -1,8 +1,8 @@ +import 'package:collaction_app/application/crowdaction/spotlight/spotlight_bloc.dart'; import 'package:dots_indicator/dots_indicator.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../../application/crowdaction/crowdaction_getter/crowdaction_getter_bloc.dart'; import '../../../infrastructure/core/injection.dart'; import '../../shared_widgets/crowdaction_card.dart'; import '../../shared_widgets/no_ripple_behavior.dart'; @@ -38,15 +38,22 @@ class _CrowdActionCarouselState extends State { : 0.9 : 1.0; - return BlocProvider( - create: (context) => getIt() - ..add(const CrowdActionGetterEvent.getMore(3)), - child: BlocBuilder( + return BlocProvider( + create: (context) => getIt() + ..add(const SpotlightEvent.getSpotLightCrowdActions()), + child: BlocBuilder( builder: (context, state) => state.when( initial: () => const CircularProgressIndicator(), - noCrowdActions: () => const Text('No CrowdActions'), - fetchingCrowdActions: () => const CircularProgressIndicator(), - fetched: (crowdActions) => Column( + fetchingCrowdSpotLightActions: () { + // TODO: Shimmer + return const Center( + child: CircularProgressIndicator(), + ); + }, + spotLightCrowdActionsError: (failure) { + return const Text('Something went wrong!'); + }, + spotLightCrowdActions: (crowdActions) => Column( children: [ SizedBox( width: MediaQuery.of(context).size.width, diff --git a/lib/presentation/home/widgets/current_upcoming_layout.dart b/lib/presentation/home/widgets/current_upcoming_layout.dart index fb61555d..6e2c106e 100644 --- a/lib/presentation/home/widgets/current_upcoming_layout.dart +++ b/lib/presentation/home/widgets/current_upcoming_layout.dart @@ -51,11 +51,8 @@ class _CurrentAndUpcomingLayoutState extends State { ), ), TextButton( - onPressed: () => context.router.push( - widget.isCurrent - ? const CrowdActionBrowseRoute() - : const CrowdActionBrowseRoute(), - ), + onPressed: () => + context.router.push(CrowdActionBrowseRoute()), child: const Text( 'View all', textAlign: TextAlign.center, diff --git a/lib/presentation/shared_widgets/micro_crowdaction_card.dart b/lib/presentation/shared_widgets/micro_crowdaction_card.dart index f7dcb3ce..ba7a1867 100644 --- a/lib/presentation/shared_widgets/micro_crowdaction_card.dart +++ b/lib/presentation/shared_widgets/micro_crowdaction_card.dart @@ -68,16 +68,14 @@ class MicroCrowdActionCard extends StatelessWidget { Row( children: [ AccentChip( - text: crowdAction.isOpen - ? "Now open" - : (crowdAction.isEnded - ? "Finished" - : "Currently running"), - color: crowdAction.isOpen + text: crowdAction.statusChipLabel, + color: crowdAction.isOpen || crowdAction.isWaiting ? kAccentColor : kPrimaryColor200, leading: Icon( - crowdAction.isOpen ? Icons.check : Icons.close, + crowdAction.isOpen || crowdAction.isWaiting + ? Icons.check + : Icons.close, color: Colors.white, ), ), diff --git a/test/application/crowdaction/crowdaction_getter_bloc_test.dart b/test/application/crowdaction/crowdaction_getter_bloc_test.dart deleted file mode 100644 index cf5730e6..00000000 --- a/test/application/crowdaction/crowdaction_getter_bloc_test.dart +++ /dev/null @@ -1,89 +0,0 @@ -import 'package:bloc_test/bloc_test.dart'; -import 'package:collaction_app/application/crowdaction/crowdaction_getter/crowdaction_getter_bloc.dart'; -import 'package:collaction_app/domain/crowdaction/crowdaction_failures.dart'; -import 'package:dartz/dartz.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; - -import '../../test_utilities.dart'; -import 'spotlight_bloc_fixtures.dart'; - -void main() { - final tCAList = [tCrowdaction, tCrowdaction.copyWith(id: 'id2')]; - group('Testing Crowdaction Getter BLoC for defined amount', () { - final caGetter = CrowdActionGetterBloc(tCrowdactionRepo); - test('Testing initial state', () { - expect( - caGetter.state, - const CrowdActionGetterState.initial(), - ); - }); - - { - when(() => tCrowdactionRepo.getCrowdActions(amount: 4)).thenAnswer( - (_) => - Future.value(left(const CrowdActionFailure.networkRequestFailed())), - ); - when(() => tCrowdactionRepo.getCrowdActions(amount: 5)).thenAnswer( - (_) => Future.error('error'), - ); - blocTest( - 'Testing getMore event error and failure', - build: () => CrowdActionGetterBloc(tCrowdactionRepo), - act: (CrowdActionGetterBloc bloc) { - bloc.add( - const CrowdActionGetterEvent.getMore(4), - ); - bloc.add( - const CrowdActionGetterEvent.getMore(5), - ); - }, - expect: () => [ - const CrowdActionGetterState.fetchingCrowdActions(), - const CrowdActionGetterState.noCrowdActions(), - const CrowdActionGetterState.fetchingCrowdActions(), - const CrowdActionGetterState.noCrowdActions(), - ], - ); - } - { - when(() => tCrowdactionRepo.getCrowdActions(amount: 2)).thenAnswer( - (_) => Future.value(right(tCAList)), - ); - - blocTest( - 'Testing getMore event success', - build: () => CrowdActionGetterBloc(tCrowdactionRepo), - act: (CrowdActionGetterBloc bloc) { - bloc.add( - const CrowdActionGetterEvent.getMore(2), - ); - }, - expect: () => [ - const CrowdActionGetterState.fetchingCrowdActions(), - CrowdActionGetterState.fetched(tCAList) - ], - ); - } - { - final tCrowdActionRepo2 = MockCrowdActionRepository(); - - when(() => tCrowdActionRepo2.getCrowdActions()).thenAnswer( - (_) => Future.value( - right(tCAList), - ), - ); - blocTest( - 'Testing getMore event when amount is not passed', - build: () => CrowdActionGetterBloc(tCrowdActionRepo2), - act: (CrowdActionGetterBloc bloc) { - bloc.add(const CrowdActionGetterEvent.getMore(null)); - }, - expect: () => [ - const CrowdActionGetterState.fetchingCrowdActions(), - CrowdActionGetterState.fetched(tCAList) - ], - ); - } - }); -} diff --git a/test/test_utilities.dart b/test/test_utilities.dart index 6cf51a4b..a297698e 100644 --- a/test/test_utilities.dart +++ b/test/test_utilities.dart @@ -74,10 +74,6 @@ class TestUtilities { final crowdActionRepo = MockCrowdActionRepository(); - when(() => crowdActionRepo.getCrowdActions()).thenAnswer( - (_) async => right(crowdActions.map((u) => u.toDomain()).toList()), - ); - when(() => crowdActionRepo.getSpotlightCrowdActions()).thenAnswer( (_) async => right(crowdActions.map((u) => u.toDomain()).toList()), ); From caef66675646ba9409129bd10436fd9fcab477ea Mon Sep 17 00:00:00 2001 From: Mathias Mogensen Date: Tue, 25 Oct 2022 17:04:22 +0200 Subject: [PATCH 3/4] fix: splash on cards and other minor things --- lib/domain/crowdaction/utils.dart | 2 +- .../crowdaction_browse_screen.dart | 62 ++--- .../crowdaction_details_screen.dart | 3 +- .../profile/widget/commitments_tab.dart | 38 +++- .../profile/widget/crowdactions_tab.dart | 2 +- .../profile/widget/signup_cta.dart | 2 +- .../commitments/commitment_card.dart | 16 +- .../commitments/commitment_card_list.dart | 3 + .../shared_widgets/crowdaction_card.dart | 211 +++++++++--------- .../custom_app_bars/custom_appbar.dart | 21 +- .../micro_crowdaction_card.dart | 41 ++-- 11 files changed, 200 insertions(+), 201 deletions(-) diff --git a/lib/domain/crowdaction/utils.dart b/lib/domain/crowdaction/utils.dart index 1d7f4a5b..8c1fde6b 100644 --- a/lib/domain/crowdaction/utils.dart +++ b/lib/domain/crowdaction/utils.dart @@ -15,4 +15,4 @@ const crowdActionCommitmentIcons = { }; IconData mapIcon(String? icon) => - crowdActionCommitmentIcons[icon] ?? CollactionIcons.no_beef; + crowdActionCommitmentIcons[icon] ?? CollactionIcons.collaction; diff --git a/lib/presentation/crowdaction/crowdaction_browse/crowdaction_browse_screen.dart b/lib/presentation/crowdaction/crowdaction_browse/crowdaction_browse_screen.dart index d5c10851..019868a4 100644 --- a/lib/presentation/crowdaction/crowdaction_browse/crowdaction_browse_screen.dart +++ b/lib/presentation/crowdaction/crowdaction_browse/crowdaction_browse_screen.dart @@ -1,4 +1,4 @@ -import 'package:auto_route/auto_route.dart'; +import 'package:collaction_app/presentation/shared_widgets/custom_app_bars/custom_appbar.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; @@ -52,50 +52,36 @@ class CrowdActionBrowsePage extends StatelessWidget { ); }, child: Scaffold( - appBar: AppBar( - elevation: 0, - backgroundColor: Colors.transparent, - leading: IconButton( - icon: const Icon( - Icons.chevron_left, - color: kPrimaryColor200, - ), - onPressed: () => context.router.pop(), - ), - title: const Text( - "Browse CrowdActions", - style: TextStyle( - fontWeight: FontWeight.bold, - color: kPrimaryColor400, - ), - ), + appBar: const CustomAppBar( + title: "Browse CrowdActions", ), body: RefreshIndicator( color: kAccentColor, onRefresh: () async { pagingController.refresh(); }, - child: PagedListView.separated( - pagingController: pagingController, - separatorBuilder: (context, index) => const SizedBox( - height: 16, - ), - builderDelegate: PagedChildBuilderDelegate( - itemBuilder: (context, crowdAction, index) => - MicroCrowdActionCard( - crowdAction, - ), - firstPageProgressIndicatorBuilder: (context) => const Center( - child: CircularProgressIndicator(color: kAccentColor), - ), - newPageProgressIndicatorBuilder: (context) => const Center( - child: CircularProgressIndicator(color: kAccentColor), - ), - firstPageErrorIndicatorBuilder: (context) => const Text( - 'Something went wrong, try to refresh by dragging down', + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10.0), + child: PagedListView.separated( + pagingController: pagingController, + separatorBuilder: (_, __) => const SizedBox(height: 20), + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, crowdAction, index) => + MicroCrowdActionCard( + crowdAction, + ), + firstPageProgressIndicatorBuilder: (context) => const Center( + child: CircularProgressIndicator(color: kAccentColor), + ), + newPageProgressIndicatorBuilder: (context) => const Center( + child: CircularProgressIndicator(color: kAccentColor), + ), + firstPageErrorIndicatorBuilder: (context) => const Text( + 'Something went wrong, try to refresh by dragging down', + ), + noItemsFoundIndicatorBuilder: (context) => + const Text('No crowdactions yet!'), ), - noItemsFoundIndicatorBuilder: (context) => - const Text('No crowdactions yet!'), ), ), ), diff --git a/lib/presentation/crowdaction/crowdaction_details/crowdaction_details_screen.dart b/lib/presentation/crowdaction/crowdaction_details/crowdaction_details_screen.dart index 037c73e9..b58bb25f 100644 --- a/lib/presentation/crowdaction/crowdaction_details/crowdaction_details_screen.dart +++ b/lib/presentation/crowdaction/crowdaction_details/crowdaction_details_screen.dart @@ -235,6 +235,7 @@ class CrowdActionDetailsPageState extends State { ), ), CommitmentCardList( + isEnded: crowdAction?.isClosed ?? true, commitmentOptions: crowdAction?.commitmentOptions, selectedCommitments: selectedCommitments, ), @@ -344,7 +345,7 @@ class CrowdActionDetailsPageState extends State { height: 20, ), PillButton( - text: "Create account", + text: "Sign in", onTap: () => _createAccount(context), margin: EdgeInsets.zero, ), diff --git a/lib/presentation/profile/widget/commitments_tab.dart b/lib/presentation/profile/widget/commitments_tab.dart index 451e5fcd..b9ccf7be 100644 --- a/lib/presentation/profile/widget/commitments_tab.dart +++ b/lib/presentation/profile/widget/commitments_tab.dart @@ -1,3 +1,4 @@ +import 'package:auto_route/auto_route.dart'; import 'package:collaction_app/domain/crowdaction/crowdaction.dart'; import 'package:collaction_app/domain/user/user.dart'; import 'package:collaction_app/presentation/profile/widget/signup_cta.dart'; @@ -5,6 +6,8 @@ import 'package:collaction_app/presentation/shared_widgets/commitments/commitmen import 'package:collaction_app/presentation/themes/constants.dart'; import 'package:flutter/material.dart'; +import '../../routes/app_routes.gr.dart'; + class CommitmentsTab extends StatelessWidget { final User? user; final List? crowdActions; @@ -40,19 +43,32 @@ class CommitmentsTab extends StatelessWidget { ), ), const SizedBox(height: 20), - ...crowdActions!.map( - (c) => Column( - children: [ - ...c.commitmentOptions.map( - (co) => CommitmentCard( - commitment: co, - viewOnly: true, + ...crowdActions! + .where((crowdAction) => crowdAction.isRunning) + .map( + (crowdAction) => GestureDetector( + onTap: () => context.router.push( + CrowdActionDetailsRoute(crowdAction: crowdAction), + ), + child: Column( + children: [ + Text( + crowdAction.title, + style: const TextStyle( + fontWeight: FontWeight.w500, + ), + ), + ...crowdAction.commitmentOptions.map( + (co) => CommitmentCard( + commitment: co, + viewOnly: true, + ), + ), + const SizedBox(height: 15) + ], ), ), - const SizedBox(height: 10) - ], - ), - ), + ), ], if (user == null || (crowdActions?.isEmpty ?? false)) ...[ SignUpCTA( diff --git a/lib/presentation/profile/widget/crowdactions_tab.dart b/lib/presentation/profile/widget/crowdactions_tab.dart index 63be4a9c..abdae809 100644 --- a/lib/presentation/profile/widget/crowdactions_tab.dart +++ b/lib/presentation/profile/widget/crowdactions_tab.dart @@ -45,7 +45,7 @@ class CrowdActionsTab extends StatelessWidget { (c) => Column( children: [ MicroCrowdActionCard(c), - const SizedBox(height: 10) + const SizedBox(height: 10), ], ), ), diff --git a/lib/presentation/profile/widget/signup_cta.dart b/lib/presentation/profile/widget/signup_cta.dart index 3e9d662e..67aec202 100644 --- a/lib/presentation/profile/widget/signup_cta.dart +++ b/lib/presentation/profile/widget/signup_cta.dart @@ -47,7 +47,7 @@ class SignUpCTA extends StatelessWidget { ), const SizedBox(height: 40), PillButton( - text: 'Create account or sign in', + text: 'Sign in', onTap: () { context.router.push(const AuthRoute()); }, diff --git a/lib/presentation/shared_widgets/commitments/commitment_card.dart b/lib/presentation/shared_widgets/commitments/commitment_card.dart index e7d92375..3118e7da 100644 --- a/lib/presentation/shared_widgets/commitments/commitment_card.dart +++ b/lib/presentation/shared_widgets/commitments/commitment_card.dart @@ -31,13 +31,15 @@ class CommitmentCard extends StatelessWidget { Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; return GestureDetector( - onTap: () { - if (!active && !deactivated) { - onSelected?.call(commitment); - } else { - onDeSelected?.call(commitment); - } - }, + onTap: viewOnly + ? null + : () { + if (!active && !deactivated) { + onSelected?.call(commitment); + } else { + onDeSelected?.call(commitment); + } + }, child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(20.0), diff --git a/lib/presentation/shared_widgets/commitments/commitment_card_list.dart b/lib/presentation/shared_widgets/commitments/commitment_card_list.dart index d7d3e64e..f2fb18f1 100644 --- a/lib/presentation/shared_widgets/commitments/commitment_card_list.dart +++ b/lib/presentation/shared_widgets/commitments/commitment_card_list.dart @@ -7,12 +7,14 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'commitment_card.dart'; class CommitmentCardList extends StatefulWidget { + final bool isEnded; final List? commitmentOptions; final List selectedCommitments; /// Widget for easily creating a list of CommitmentCard(s) const CommitmentCardList({ super.key, + this.isEnded = false, required this.commitmentOptions, required this.selectedCommitments, }); @@ -57,6 +59,7 @@ class _CommitmentCardListState extends State { onDeSelected: isParticipating ? null : deselectCommitment, active: widget.selectedCommitments.contains(option), deactivated: isParticipating || isBlocked(option), + viewOnly: widget.isEnded, ); }, itemCount: widget.commitmentOptions!.length, diff --git a/lib/presentation/shared_widgets/crowdaction_card.dart b/lib/presentation/shared_widgets/crowdaction_card.dart index ea1ae840..e897cafa 100644 --- a/lib/presentation/shared_widgets/crowdaction_card.dart +++ b/lib/presentation/shared_widgets/crowdaction_card.dart @@ -24,124 +24,119 @@ class CrowdActionCard extends StatelessWidget { @override Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap ?? - () { - if (crowdAction.hasPassword) { - showPasswordModal(context, crowdAction); - } else { - context.router.push( - CrowdActionDetailsRoute(crowdAction: crowdAction), - ); - } - }, - child: Container( - margin: const EdgeInsets.all(12.0), - decoration: BoxDecoration( + return Container( + margin: const EdgeInsets.all(12.0), + child: Material( + borderRadius: BorderRadius.circular(20.0), + color: kSecondaryColor, + elevation: 4, + child: InkWell( borderRadius: BorderRadius.circular(20.0), - boxShadow: const [ - BoxShadow( - color: kShadowColor, - blurRadius: 4.0, - offset: Offset(0, 4), + onTap: onTap ?? + () { + if (crowdAction.hasPassword) { + showPasswordModal(context, crowdAction); + } else { + context.router.push( + CrowdActionDetailsRoute(crowdAction: crowdAction), + ); + } + }, + child: Container( + height: 400 * scaleFactor, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20.0), ), - ], - ), - child: Container( - height: 400 * scaleFactor, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20.0), - color: kSecondaryColor, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - height: 215 * scaleFactor, - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(20.0), - topRight: Radius.circular(20.0), - ), - image: DecorationImage( - fit: BoxFit.cover, - image: CachedNetworkImageProvider( - '${dotenv.get('BASE_STATIC_ENDPOINT_URL')}/${crowdAction.images.card}', - errorListener: () {}, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 215 * scaleFactor, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(20.0), + topRight: Radius.circular(20.0), + ), + image: DecorationImage( + fit: BoxFit.cover, + image: CachedNetworkImageProvider( + '${dotenv.get('BASE_STATIC_ENDPOINT_URL')}/${crowdAction.images.card}', + errorListener: () {}, + ), ), ), - ), - child: crowdAction.hasPassword - ? Stack( - children: const [ - Positioned( - bottom: 10, - right: 10, - child: CustomFAB( - heroTag: 'locked', - isMini: true, - color: kSecondaryColor, - child: Icon( - CollactionIcons.lock, - color: kPrimaryColor300, + child: crowdAction.hasPassword + ? Stack( + children: const [ + Positioned( + bottom: 10, + right: 10, + child: CustomFAB( + heroTag: 'locked', + isMini: true, + color: kSecondaryColor, + child: Icon( + CollactionIcons.lock, + color: kPrimaryColor300, + ), ), - ), - ) + ) + ], + ) + : null, + ), + const SizedBox(height: 5.0), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + const SizedBox(width: 15.0), + Wrap( + spacing: 12.0, + children: crowdAction.toChips(), + ), ], - ) - : null, - ), - const SizedBox(height: 5.0), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - const SizedBox(width: 15.0), - Wrap( - spacing: 12.0, - children: crowdAction.toChips(), - ), - ], + ), ), - ), - const SizedBox(height: 5.0), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 15.0), - child: Column( - children: [ - Text( - crowdAction.title, - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 22.0 * scaleFactor, - fontWeight: FontWeight.bold, - color: kPrimaryColor400, + const SizedBox(height: 5.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 15.0), + child: Column( + children: [ + Text( + crowdAction.title, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 22.0 * scaleFactor, + fontWeight: FontWeight.bold, + color: kPrimaryColor400, + ), ), - ), - ], + ], + ), ), - ), - const SizedBox(height: 18.0), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 15.0), - child: Text( - crowdAction.description, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: Theme.of(context) - .textTheme - .bodyText2 - ?.copyWith(color: kInactiveColor), + const SizedBox(height: 18.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 15.0), + child: Text( + crowdAction.description, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .bodyText2 + ?.copyWith(color: kInactiveColor), + ), ), - ), - ], - ), - ], + ], + ), + ], + ), ), ), ), diff --git a/lib/presentation/shared_widgets/custom_app_bars/custom_appbar.dart b/lib/presentation/shared_widgets/custom_app_bars/custom_appbar.dart index a5a11c14..be10ad26 100644 --- a/lib/presentation/shared_widgets/custom_app_bars/custom_appbar.dart +++ b/lib/presentation/shared_widgets/custom_app_bars/custom_appbar.dart @@ -24,20 +24,17 @@ class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { leading: !closable ? Padding( padding: const EdgeInsets.all(8.0), - child: ElevatedButton( - onPressed: () => context.router.pop(), - style: ButtonStyle( - shape: MaterialStateProperty.all(const CircleBorder()), - backgroundColor: MaterialStateProperty.all(kSecondaryColor), - elevation: MaterialStateProperty.all(4.0), - padding: MaterialStateProperty.all( - const EdgeInsets.all(8.0), + child: Material( + borderRadius: BorderRadius.circular(20), + color: Colors.white, + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () => context.router.pop(), + child: const Icon( + CollactionIcons.left, + color: kPrimaryColor400, ), ), - child: const Icon( - CollactionIcons.left, - color: kPrimaryColor300, - ), ), ) : null, diff --git a/lib/presentation/shared_widgets/micro_crowdaction_card.dart b/lib/presentation/shared_widgets/micro_crowdaction_card.dart index ba7a1867..dc0b9c37 100644 --- a/lib/presentation/shared_widgets/micro_crowdaction_card.dart +++ b/lib/presentation/shared_widgets/micro_crowdaction_card.dart @@ -20,27 +20,26 @@ class MicroCrowdActionCard extends StatelessWidget { @override Widget build(BuildContext context) { - return GestureDetector( - onTap: () { - if (crowdAction.hasPassword) { - showPasswordModal(context, crowdAction); - } else { - context.router.push( - CrowdActionDetailsRoute( - crowdActionId: crowdAction.id, - ), - ); - } - }, - child: Container( - height: 148, - width: MediaQuery.of(context).size.width, - margin: const EdgeInsets.symmetric(vertical: 10), - child: Card( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - elevation: 4, + return Material( + borderRadius: BorderRadius.circular(20), + color: Colors.white, + elevation: 4, + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () { + if (crowdAction.hasPassword) { + showPasswordModal(context, crowdAction); + } else { + context.router.push( + CrowdActionDetailsRoute( + crowdActionId: crowdAction.id, + ), + ); + } + }, + child: SizedBox( + height: 148, + width: MediaQuery.of(context).size.width, child: Row( children: [ Container( From 5ca989c182973c36963b9a694d9aeff846d007d2 Mon Sep 17 00:00:00 2001 From: Mathias Mogensen Date: Wed, 26 Oct 2022 11:12:50 +0200 Subject: [PATCH 4/4] feat: top participants in crowdaction card and details --- .../top_participants_bloc.dart | 34 +++++ .../top_participants_event.dart | 8 ++ .../top_participants_state.dart | 11 ++ .../i_participation_repository.dart | 4 + .../participation_repository.dart | 29 ++++ .../crowdaction_details_screen.dart | 4 +- .../widgets/participants.dart | 37 +++++ .../widgets/participation_count_text.dart | 20 ++- .../profile/widget/commitments_tab.dart | 1 + .../profile/widget/profile_picture.dart | 1 - .../shared_widgets/crowdaction_card.dart | 45 ++++-- .../shared_widgets/participant_avatars.dart | 128 ++++++++++++------ .../shimmers/top_participants_shimmer.dart | 50 +++++++ .../shared_widgets/pin_input/mocks.dart | 27 ---- .../pin_input/pin_input_test.dart | 58 -------- .../pin_input/pin_input_test.ext.dart | 22 --- 16 files changed, 313 insertions(+), 166 deletions(-) create mode 100644 lib/application/participation/top_participants/top_participants_bloc.dart create mode 100644 lib/application/participation/top_participants/top_participants_event.dart create mode 100644 lib/application/participation/top_participants/top_participants_state.dart create mode 100644 lib/presentation/crowdaction/crowdaction_details/widgets/participants.dart create mode 100644 lib/presentation/shared_widgets/shimmers/top_participants_shimmer.dart delete mode 100644 test/presentation/shared_widgets/pin_input/mocks.dart delete mode 100644 test/presentation/shared_widgets/pin_input/pin_input_test.dart delete mode 100644 test/presentation/shared_widgets/pin_input/pin_input_test.ext.dart diff --git a/lib/application/participation/top_participants/top_participants_bloc.dart b/lib/application/participation/top_participants/top_participants_bloc.dart new file mode 100644 index 00000000..15b0e5c7 --- /dev/null +++ b/lib/application/participation/top_participants/top_participants_bloc.dart @@ -0,0 +1,34 @@ +import 'package:bloc/bloc.dart'; +import 'package:collaction_app/domain/participation/i_participation_repository.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:injectable/injectable.dart'; + +import '../../../domain/participation/participation.dart'; + +part 'top_participants_event.dart'; +part 'top_participants_state.dart'; +part 'top_participants_bloc.freezed.dart'; + +@injectable +class TopParticipantsBloc + extends Bloc { + final IParticipationRepository _participationRepository; + + TopParticipantsBloc(this._participationRepository) : super(const _Initial()) { + on((event, emit) async { + await event.map( + fetchParticipants: (event) async { + emit(const TopParticipantsState.fetching()); + + final participantsOrFailure = await _participationRepository + .getTopParticipants(crowdActionId: event.crowdActionId); + + participantsOrFailure.fold( + (failure) => emit(const TopParticipantsState.failure()), + (participants) => emit(TopParticipantsState.fetched(participants)), + ); + }, + ); + }); + } +} diff --git a/lib/application/participation/top_participants/top_participants_event.dart b/lib/application/participation/top_participants/top_participants_event.dart new file mode 100644 index 00000000..ba4bc387 --- /dev/null +++ b/lib/application/participation/top_participants/top_participants_event.dart @@ -0,0 +1,8 @@ +part of 'top_participants_bloc.dart'; + +@freezed +class TopParticipantsEvent with _$TopParticipantsEvent { + const factory TopParticipantsEvent.fetchParticipants({ + required String crowdActionId, + }) = _FetchParticipants; +} diff --git a/lib/application/participation/top_participants/top_participants_state.dart b/lib/application/participation/top_participants/top_participants_state.dart new file mode 100644 index 00000000..99061e0b --- /dev/null +++ b/lib/application/participation/top_participants/top_participants_state.dart @@ -0,0 +1,11 @@ +part of 'top_participants_bloc.dart'; + +@freezed +class TopParticipantsState with _$TopParticipantsState { + const factory TopParticipantsState.initial() = _Initial; + const factory TopParticipantsState.fetching() = _Fetching; + const factory TopParticipantsState.fetched( + List topParticipants, + ) = _Fetched; + const factory TopParticipantsState.failure() = _Failure; +} diff --git a/lib/domain/participation/i_participation_repository.dart b/lib/domain/participation/i_participation_repository.dart index 285f540a..29f80a0e 100644 --- a/lib/domain/participation/i_participation_repository.dart +++ b/lib/domain/participation/i_participation_repository.dart @@ -18,4 +18,8 @@ abstract class IParticipationRepository { required String crowdActionId, int pageNumber = 1, }); + + Future>> getTopParticipants({ + required String crowdActionId, + }); } diff --git a/lib/infrastructure/participation/participation_repository.dart b/lib/infrastructure/participation/participation_repository.dart index 3a91a4fd..845de214 100644 --- a/lib/infrastructure/participation/participation_repository.dart +++ b/lib/infrastructure/participation/participation_repository.dart @@ -137,4 +137,33 @@ class ParticipationRepository implements IParticipationRepository { return left(const ParticipationFailure.networkRequestFailed()); } } + + @override + Future>> getTopParticipants({ + required String crowdActionId, + }) async { + try { + final response = await client.get( + Uri.parse( + '${await settingsRepository.baseApiEndpointUrl}/v1/participations?crowdActionId=$crowdActionId&pageSize=3', + ), + ); + + if (response.statusCode == 200) { + final json = jsonDecode(response.body)['items'] as List; + final participations = json + .map( + (item) => ParticipationDto.fromJson(item as Map) + .toDomain(), + ) + .toList(); + + return right(participations); + } + + return left(const ParticipationFailure.serverError()); + } catch (ex) { + return left(const ParticipationFailure.networkRequestFailed()); + } + } } diff --git a/lib/presentation/crowdaction/crowdaction_details/crowdaction_details_screen.dart b/lib/presentation/crowdaction/crowdaction_details/crowdaction_details_screen.dart index b58bb25f..1e9c238f 100644 --- a/lib/presentation/crowdaction/crowdaction_details/crowdaction_details_screen.dart +++ b/lib/presentation/crowdaction/crowdaction_details/crowdaction_details_screen.dart @@ -6,7 +6,7 @@ import 'package:collaction_app/application/user/profile_tab/profile_tab_bloc.dar import 'package:collaction_app/presentation/crowdaction/crowdaction_details/widgets/crowdaction_chips.dart'; import 'package:collaction_app/presentation/crowdaction/crowdaction_details/widgets/crowdaction_details_banner.dart'; import 'package:collaction_app/presentation/crowdaction/crowdaction_details/widgets/crowdaction_title.dart'; -import 'package:collaction_app/presentation/crowdaction/crowdaction_details/widgets/participation_count_text.dart'; +import 'package:collaction_app/presentation/crowdaction/crowdaction_details/widgets/participants.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:shimmer/shimmer.dart'; @@ -185,7 +185,7 @@ class CrowdActionDetailsPageState extends State { subCategory: crowdAction?.subcategory, ), const SizedBox(height: 20), - ParticipationCountText( + Participants( crowdAction: crowdAction, ), const SizedBox(height: 20), diff --git a/lib/presentation/crowdaction/crowdaction_details/widgets/participants.dart b/lib/presentation/crowdaction/crowdaction_details/widgets/participants.dart new file mode 100644 index 00000000..07fa8dae --- /dev/null +++ b/lib/presentation/crowdaction/crowdaction_details/widgets/participants.dart @@ -0,0 +1,37 @@ +import 'package:collaction_app/presentation/crowdaction/crowdaction_details/widgets/participation_count_text.dart'; +import 'package:flutter/material.dart'; + +import '../../../../domain/crowdaction/crowdaction.dart'; +import '../../../shared_widgets/participant_avatars.dart'; + +class Participants extends StatelessWidget { + final CrowdAction? crowdAction; + + const Participants({ + super.key, + this.crowdAction, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + if (crowdAction != null && + (crowdAction?.participantCount ?? 0) > 0) ...[ + TopParticipantAvatars( + crowdActionId: crowdAction!.id, + ), + const SizedBox( + width: 20, + ), + ], + Flexible( + child: ParticipationCountText( + crowdAction: crowdAction, + isEnded: crowdAction?.isClosed ?? false, + ), + ), + ], + ); + } +} diff --git a/lib/presentation/crowdaction/crowdaction_details/widgets/participation_count_text.dart b/lib/presentation/crowdaction/crowdaction_details/widgets/participation_count_text.dart index 39b680af..0ed656cb 100644 --- a/lib/presentation/crowdaction/crowdaction_details/widgets/participation_count_text.dart +++ b/lib/presentation/crowdaction/crowdaction_details/widgets/participation_count_text.dart @@ -6,15 +6,31 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:shimmer/shimmer.dart'; import '../../../../domain/crowdaction/crowdaction.dart'; +import '../../../../infrastructure/core/injection.dart'; import '../../../../presentation/themes/constants.dart'; class ParticipationCountText extends StatelessWidget { const ParticipationCountText({ super.key, required this.crowdAction, + this.isEnded = false, }); final CrowdAction? crowdAction; + final bool isEnded; + + CrowdActionDetailsBloc getDetailsBloc(BuildContext context) { + try { + return BlocProvider.of(context); + } catch (_) { + return getIt() + ..add( + CrowdActionDetailsEvent.fetchCrowdAction( + id: crowdAction!.id, + ), + ); + } + } @override Widget build(BuildContext context) { @@ -27,7 +43,7 @@ class ParticipationCountText extends StatelessWidget { ), ), child: BlocProvider.value( - value: BlocProvider.of(context), + value: getDetailsBloc(context), child: BlocBuilder( builder: (context, state) { return state.when( @@ -74,7 +90,7 @@ class ParticipationCountText extends StatelessWidget { ); Text participantCountText(BuildContext context, int participantCount) => Text( - "Join $participantCount participant${participantCount == 1 ? "" : "s"}", + "${!isEnded ? 'Join ' : ''}$participantCount ${participantCount > 1 ? 'people' : 'person'} ${!isEnded ? 'participating' : 'participated'}", style: Theme.of(context).textTheme.caption?.copyWith( fontSize: 14, color: kPrimaryColor300, diff --git a/lib/presentation/profile/widget/commitments_tab.dart b/lib/presentation/profile/widget/commitments_tab.dart index b9ccf7be..0da31c91 100644 --- a/lib/presentation/profile/widget/commitments_tab.dart +++ b/lib/presentation/profile/widget/commitments_tab.dart @@ -51,6 +51,7 @@ class CommitmentsTab extends StatelessWidget { CrowdActionDetailsRoute(crowdAction: crowdAction), ), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( crowdAction.title, diff --git a/lib/presentation/profile/widget/profile_picture.dart b/lib/presentation/profile/widget/profile_picture.dart index f8b8bd7b..7a790567 100644 --- a/lib/presentation/profile/widget/profile_picture.dart +++ b/lib/presentation/profile/widget/profile_picture.dart @@ -37,7 +37,6 @@ class ProfilePicture extends StatelessWidget { backgroundImage: const AssetImage( 'assets/images/default_avatar.png', ), - child: const SizedBox.shrink(), onForegroundImageError: (_, __) {}, ); } diff --git a/lib/presentation/shared_widgets/crowdaction_card.dart b/lib/presentation/shared_widgets/crowdaction_card.dart index e897cafa..64af519e 100644 --- a/lib/presentation/shared_widgets/crowdaction_card.dart +++ b/lib/presentation/shared_widgets/crowdaction_card.dart @@ -1,5 +1,6 @@ import 'package:auto_route/auto_route.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:collaction_app/presentation/crowdaction/crowdaction_details/widgets/participants.dart'; import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; @@ -10,7 +11,7 @@ import '../routes/app_routes.gr.dart'; import '../themes/constants.dart'; import 'custom_fab.dart'; -class CrowdActionCard extends StatelessWidget { +class CrowdActionCard extends StatefulWidget { final CrowdAction crowdAction; final double scaleFactor; final Function()? onTap; @@ -22,8 +23,16 @@ class CrowdActionCard extends StatelessWidget { this.onTap, }); + @override + State createState() => _CrowdActionCardState(); +} + +class _CrowdActionCardState extends State + with AutomaticKeepAliveClientMixin { @override Widget build(BuildContext context) { + super.build(context); + return Container( margin: const EdgeInsets.all(12.0), child: Material( @@ -32,18 +41,18 @@ class CrowdActionCard extends StatelessWidget { elevation: 4, child: InkWell( borderRadius: BorderRadius.circular(20.0), - onTap: onTap ?? + onTap: widget.onTap ?? () { - if (crowdAction.hasPassword) { - showPasswordModal(context, crowdAction); + if (widget.crowdAction.hasPassword) { + showPasswordModal(context, widget.crowdAction); } else { context.router.push( - CrowdActionDetailsRoute(crowdAction: crowdAction), + CrowdActionDetailsRoute(crowdAction: widget.crowdAction), ); } }, child: Container( - height: 400 * scaleFactor, + height: 460 * widget.scaleFactor, decoration: BoxDecoration( borderRadius: BorderRadius.circular(20.0), ), @@ -51,7 +60,7 @@ class CrowdActionCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( - height: 215 * scaleFactor, + height: 215 * widget.scaleFactor, decoration: BoxDecoration( color: Colors.grey[300], borderRadius: const BorderRadius.only( @@ -61,12 +70,12 @@ class CrowdActionCard extends StatelessWidget { image: DecorationImage( fit: BoxFit.cover, image: CachedNetworkImageProvider( - '${dotenv.get('BASE_STATIC_ENDPOINT_URL')}/${crowdAction.images.card}', + '${dotenv.get('BASE_STATIC_ENDPOINT_URL')}/${widget.crowdAction.images.card}', errorListener: () {}, ), ), ), - child: crowdAction.hasPassword + child: widget.crowdAction.hasPassword ? Stack( children: const [ Positioned( @@ -97,7 +106,7 @@ class CrowdActionCard extends StatelessWidget { const SizedBox(width: 15.0), Wrap( spacing: 12.0, - children: crowdAction.toChips(), + children: widget.crowdAction.toChips(), ), ], ), @@ -108,11 +117,11 @@ class CrowdActionCard extends StatelessWidget { child: Column( children: [ Text( - crowdAction.title, + widget.crowdAction.title, maxLines: 3, overflow: TextOverflow.ellipsis, style: TextStyle( - fontSize: 22.0 * scaleFactor, + fontSize: 22.0 * widget.scaleFactor, fontWeight: FontWeight.bold, color: kPrimaryColor400, ), @@ -124,7 +133,7 @@ class CrowdActionCard extends StatelessWidget { Padding( padding: const EdgeInsets.symmetric(horizontal: 15.0), child: Text( - crowdAction.description, + widget.crowdAction.description, maxLines: 2, overflow: TextOverflow.ellipsis, style: Theme.of(context) @@ -133,6 +142,13 @@ class CrowdActionCard extends StatelessWidget { ?.copyWith(color: kInactiveColor), ), ), + if (widget.crowdAction.participantCount > 0) ...[ + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 15.0), + child: Participants(crowdAction: widget.crowdAction), + ), + ], ], ), ], @@ -142,4 +158,7 @@ class CrowdActionCard extends StatelessWidget { ), ); } + + @override + bool get wantKeepAlive => true; } diff --git a/lib/presentation/shared_widgets/participant_avatars.dart b/lib/presentation/shared_widgets/participant_avatars.dart index 69bcad3c..39602eef 100644 --- a/lib/presentation/shared_widgets/participant_avatars.dart +++ b/lib/presentation/shared_widgets/participant_avatars.dart @@ -1,61 +1,107 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:collaction_app/application/participation/top_participants/top_participants_bloc.dart'; +import 'package:collaction_app/domain/participation/participation.dart'; +import 'package:collaction_app/presentation/shared_widgets/shimmers/top_participants_shimmer.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:shimmer/shimmer.dart'; -import '../../domain/crowdaction/crowdaction.dart'; +import '../../infrastructure/core/injection.dart'; +import '../themes/constants.dart'; -class ParticipantAvatars extends StatelessWidget { - final List participants; +class TopParticipantAvatars extends StatelessWidget { + final String crowdActionId; - const ParticipantAvatars({ + const TopParticipantAvatars({ super.key, - required this.participants, + required this.crowdActionId, }); @override Widget build(BuildContext context) { - return Stack( - children: participants - .take(3) - .toList() - .asMap() - .entries - .map( - (participant) => _createAvatar(participant.value, participant.key), - ) - .toList(), + return BlocProvider( + create: (context) => getIt() + ..add( + TopParticipantsEvent.fetchParticipants( + crowdActionId: crowdActionId, + ), + ), + child: BlocBuilder( + builder: (context, state) { + return state.map( + initial: (_) => const SizedBox.shrink(), + fetching: (_) => Shimmer.fromColors( + baseColor: kPrimaryColor100, + highlightColor: kPrimaryColor200, + child: const TopParticipantsShimmer(), + ), + fetched: (state) => SizedBox( + width: state.topParticipants.length == 1 + ? 40.0 + : state.topParticipants.length == 3 + ? 90.0 + : 65.0, + child: Stack( + children: state.topParticipants + .asMap() + .entries + .map( + (participant) => _createAvatar( + participant.value, + participant.key, + state.topParticipants.length, + ), + ) + .toList(), + ), + ), + failure: (_) => const SizedBox.shrink(), + ); + }, + ), ); } - Align _createAvatar(TopParticipant participant, int index) { + Align _createAvatar(Participation participant, int index, int amount) { return Align( - alignment: _getIndexAlignment(index), - child: CircleAvatar( - backgroundColor: Colors.white, - child: participant.imageUrl != null - ? CircleAvatar( - radius: 18, - backgroundColor: Colors.grey[300], - backgroundImage: NetworkImage( - participant.imageUrl!, - ), // Provide your custom image - ) - : DecoratedBox( - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(20), - ), - ), + alignment: _getIndexAlignment(index, amount), + child: Container( + padding: const EdgeInsets.all(2), + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + child: CircleAvatar( + radius: 18, + backgroundColor: Colors.grey[300], + foregroundImage: CachedNetworkImageProvider( + '${dotenv.get('BASE_STATIC_ENDPOINT_URL')}/${participant.avatar}', + errorListener: () {}, + ), + backgroundImage: const AssetImage('assets/images/default_avatar.png'), + onForegroundImageError: (_, __) {}, + ), ), ); } - Alignment _getIndexAlignment(int index) { - switch (index) { - case 0: - return Alignment.centerLeft; - case 1: - return Alignment.center; - default: - return Alignment.centerRight; + Alignment _getIndexAlignment(int index, int amount) { + if (amount == 3) { + switch (index) { + case 0: + return Alignment.centerRight; + case 1: + return Alignment.center; + default: + return Alignment.centerLeft; + } } + + if (amount == 2) { + return index == 1 ? Alignment.centerLeft : Alignment.centerRight; + } + + return Alignment.centerLeft; } } diff --git a/lib/presentation/shared_widgets/shimmers/top_participants_shimmer.dart b/lib/presentation/shared_widgets/shimmers/top_participants_shimmer.dart new file mode 100644 index 00000000..810c2855 --- /dev/null +++ b/lib/presentation/shared_widgets/shimmers/top_participants_shimmer.dart @@ -0,0 +1,50 @@ +import 'package:collaction_app/presentation/themes/constants.dart'; +import 'package:flutter/material.dart'; + +class TopParticipantsShimmer extends StatelessWidget { + const TopParticipantsShimmer(); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 90, + child: Stack( + children: [ + Align( + alignment: Alignment.centerRight, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: kSecondaryTransparent, + borderRadius: BorderRadius.circular(20), + ), + ), + ), + Align( + child: Container( + width: 40, + height: 40, + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: kSecondaryTransparent, + borderRadius: BorderRadius.circular(20), + ), + ), + ), + Align( + alignment: Alignment.centerLeft, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: kSecondaryTransparent, + borderRadius: BorderRadius.circular(20), + ), + ), + ), + ], + ), + ); + } +} diff --git a/test/presentation/shared_widgets/pin_input/mocks.dart b/test/presentation/shared_widgets/pin_input/mocks.dart deleted file mode 100644 index 6a58cfa5..00000000 --- a/test/presentation/shared_widgets/pin_input/mocks.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; - -class MockClipboard { - dynamic _clipboardData = { - 'text': null, - }; - - MockClipboard() { - SystemChannels.platform.setMockMethodCallHandler(handleMethodCall); - } - - Future handleMethodCall(MethodCall methodCall) async { - switch (methodCall.method) { - case 'Clipboard.getData': - return _clipboardData; - case 'Clipboard.setData': - _clipboardData = methodCall.arguments; - break; - } - } - - /// Invoke the clipboard setData method - void setClipboardData(String? text) { - SystemChannels.platform.invokeMethod('Clipboard.setData', {'text': text}); - } -} diff --git a/test/presentation/shared_widgets/pin_input/pin_input_test.dart b/test/presentation/shared_widgets/pin_input/pin_input_test.dart deleted file mode 100644 index fc8a7a3a..00000000 --- a/test/presentation/shared_widgets/pin_input/pin_input_test.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:collaction_app/presentation/shared_widgets/pin_input/pin_input.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'mocks.dart'; - -part 'pin_input_test.ext.dart'; - -void main() { - late MockClipboard clipboard; - - setUpAll(() { - clipboard = MockClipboard(); - }); - - testWidgets( - 'should submit PIN ' - 'when long pressed and clipboard has PIN', (WidgetTester tester) async { - // arrange - const pinCode = '000000'; - - // Put pin in clipboard - clipboard.setClipboardData(pinCode); - - String? submittedCode; - await tester.pumpPinInputWidget((pin) { - submittedCode = pin; - }); - - // Long press first field to paste - await tester.longPress(find.byKey(const ValueKey('pin_input_0'))); - - // assert - expect(submittedCode, pinCode); - }); - - testWidgets( - 'should do nothing ' - 'when long pressed and clipboard has invalid PIN', - (WidgetTester tester) async { - // arrange - const invalidPin = 'some weird text'; - - // Set invalid clipboard data - clipboard.setClipboardData(invalidPin); - - bool isSubmitted = false; - await tester.pumpPinInputWidget((_) { - isSubmitted = true; - }); - - // Long press first field to paste - await tester.longPress(find.byKey(const ValueKey('pin_input_0'))); - - // assert - expect(isSubmitted, false); - }); -} diff --git a/test/presentation/shared_widgets/pin_input/pin_input_test.ext.dart b/test/presentation/shared_widgets/pin_input/pin_input_test.ext.dart deleted file mode 100644 index f08de198..00000000 --- a/test/presentation/shared_widgets/pin_input/pin_input_test.ext.dart +++ /dev/null @@ -1,22 +0,0 @@ -part of 'pin_input_test.dart'; - -extension WidgetTesterX on WidgetTester { - Future pumpPinInputWidget(Function(String) onSubmit) async { - late BuildContext ctx; - - await pumpWidget( - MaterialApp( - home: Builder( - builder: (context) { - ctx = context; - return Material( - child: Scaffold(body: PinInput(submit: onSubmit)), - ); - }, - ), - ), - ); - - return ctx; - } -}