diff --git a/packages/smooth_app/lib/cards/product_cards/product_title_card.dart b/packages/smooth_app/lib/cards/product_cards/product_title_card.dart index 27b7c94ff46..a6ccb6dc001 100644 --- a/packages/smooth_app/lib/cards/product_cards/product_title_card.dart +++ b/packages/smooth_app/lib/cards/product_cards/product_title_card.dart @@ -29,7 +29,7 @@ class ProductTitleCard extends StatelessWidget { onRemove: onRemove, ); - if (!(isRemovable && !isSelectable)) { + if (!dense && !(isRemovable && !isSelectable)) { title = Expanded(child: title); } @@ -46,6 +46,7 @@ class ProductTitleCard extends StatelessWidget { Expanded( child: _ProductTitleCardName( selectable: isSelectable, + dense: dense, ), ), title, @@ -65,8 +66,10 @@ class ProductTitleCard extends StatelessWidget { class _ProductTitleCardName extends StatelessWidget { const _ProductTitleCardName({ required this.selectable, + this.dense = false, }); + final bool dense; final bool selectable; @override @@ -78,7 +81,7 @@ class _ProductTitleCardName extends StatelessWidget { getProductName(product, appLocalizations), style: Theme.of(context).textTheme.headlineMedium, textAlign: TextAlign.start, - maxLines: 3, + maxLines: dense ? 2 : 3, overflow: TextOverflow.ellipsis, ).selectable(isSelectable: selectable); } diff --git a/packages/smooth_app/lib/generic_lib/buttons/smooth_large_button_with_icon.dart b/packages/smooth_app/lib/generic_lib/buttons/smooth_large_button_with_icon.dart index 07b157dc8fd..481375e9c3a 100644 --- a/packages/smooth_app/lib/generic_lib/buttons/smooth_large_button_with_icon.dart +++ b/packages/smooth_app/lib/generic_lib/buttons/smooth_large_button_with_icon.dart @@ -12,6 +12,7 @@ class SmoothLargeButtonWithIcon extends StatelessWidget { this.backgroundColor, this.foregroundColor, this.textAlign, + this.textStyle, }); final String text; @@ -22,6 +23,7 @@ class SmoothLargeButtonWithIcon extends StatelessWidget { final Color? backgroundColor; final Color? foregroundColor; final TextAlign? textAlign; + final TextStyle? textStyle; Color _getBackgroundColor(final ThemeData themeData) => backgroundColor ?? themeData.colorScheme.secondary; @@ -32,6 +34,12 @@ class SmoothLargeButtonWithIcon extends StatelessWidget { @override Widget build(BuildContext context) { final ThemeData themeData = Theme.of(context); + TextStyle style = textStyle ?? themeData.textTheme.bodyMedium!; + + if (style.color == null) { + style = style.copyWith(color: _getForegroundColor(themeData)); + } + return SmoothSimpleButton( minWidth: double.infinity, padding: padding ?? const EdgeInsets.all(10), @@ -51,9 +59,7 @@ class SmoothLargeButtonWithIcon extends StatelessWidget { text, maxLines: 2, textAlign: textAlign, - style: themeData.textTheme.bodyMedium!.copyWith( - color: _getForegroundColor(themeData), - ), + style: style, ), ), const Spacer(), diff --git a/packages/smooth_app/lib/l10n/app_en.arb b/packages/smooth_app/lib/l10n/app_en.arb index 864d25ddbc0..e73069832f0 100644 --- a/packages/smooth_app/lib/l10n/app_en.arb +++ b/packages/smooth_app/lib/l10n/app_en.arb @@ -2392,5 +2392,9 @@ "nova_group_2": "NOVA Group 2", "nova_group_3": "NOVA Group 3", "nova_group_4": "NOVA Group 4", - "nova_group_unknown": "Unknown NOVA Group" + "nova_group_unknown": "Unknown NOVA Group", + "hunger_games_loading_line1": "Please let us a few seconds…", + "hunger_games_loading_line2": "We're downloading the questions!", + "hunger_games_error_label": "Argh! Something went wrong… and we couldn't load the questions.", + "hunger_games_error_retry_button": "Let's retry!" } diff --git a/packages/smooth_app/lib/pages/hunger_games/congrats.dart b/packages/smooth_app/lib/pages/hunger_games/congrats.dart index c2483874e36..bfafb205618 100644 --- a/packages/smooth_app/lib/pages/hunger_games/congrats.dart +++ b/packages/smooth_app/lib/pages/hunger_games/congrats.dart @@ -13,6 +13,8 @@ import 'package:smooth_app/generic_lib/loading_dialog.dart'; import 'package:smooth_app/generic_lib/widgets/smooth_card.dart'; import 'package:smooth_app/pages/user_management/login_page.dart'; +typedef AnonymousAnnotationList = Map; + class CongratsWidget extends StatelessWidget { const CongratsWidget({ required this.continueButtonLabel, @@ -22,7 +24,7 @@ class CongratsWidget extends StatelessWidget { final String? continueButtonLabel; final VoidCallback? onContinue; - final Map anonymousAnnotationList; + final AnonymousAnnotationList anonymousAnnotationList; @override Widget build(BuildContext context) { @@ -69,9 +71,12 @@ class CongratsWidget extends StatelessWidget { ), Align( alignment: AlignmentDirectional.bottomEnd, - child: SmoothSimpleButton( - child: Text(appLocalizations.close), - onPressed: () => Navigator.maybePop(context), + child: Padding( + padding: const EdgeInsets.only(bottom: MEDIUM_SPACE), + child: SmoothSimpleButton( + child: Text(appLocalizations.close), + onPressed: () => Navigator.maybePop(context), + ), ), ), ], diff --git a/packages/smooth_app/lib/pages/hunger_games/question_card.dart b/packages/smooth_app/lib/pages/hunger_games/question_card.dart index 9494e6002c3..f0b2f8c665f 100755 --- a/packages/smooth_app/lib/pages/hunger_games/question_card.dart +++ b/packages/smooth_app/lib/pages/hunger_games/question_card.dart @@ -67,8 +67,12 @@ class QuestionCard extends StatelessWidget { : QuestionImageThumbnail(question), ), Padding( - padding: - const EdgeInsets.symmetric(horizontal: SMALL_SPACE), + padding: const EdgeInsetsDirectional.only( + start: SMALL_SPACE, + end: SMALL_SPACE, + top: SMALL_SPACE, + bottom: VERY_SMALL_SPACE, + ), child: Column( mainAxisSize: MainAxisSize.min, children: [ diff --git a/packages/smooth_app/lib/pages/hunger_games/question_page.dart b/packages/smooth_app/lib/pages/hunger_games/question_page.dart index ff786f43672..28788647866 100755 --- a/packages/smooth_app/lib/pages/hunger_games/question_page.dart +++ b/packages/smooth_app/lib/pages/hunger_games/question_page.dart @@ -1,5 +1,6 @@ import 'dart:ui'; +import 'package:async/async.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:matomo_tracker/matomo_tracker.dart'; @@ -7,6 +8,7 @@ import 'package:openfoodfacts/openfoodfacts.dart'; import 'package:provider/provider.dart'; import 'package:smooth_app/background/background_task_hunger_games.dart'; import 'package:smooth_app/database/local_database.dart'; +import 'package:smooth_app/generic_lib/buttons/smooth_large_button_with_icon.dart'; import 'package:smooth_app/generic_lib/design_constants.dart'; import 'package:smooth_app/generic_lib/duration_constants.dart'; import 'package:smooth_app/pages/hunger_games/congrats.dart'; @@ -76,6 +78,42 @@ Future openQuestionPage( transitionDuration: SmoothAnimationsDuration.medium, ); +class _CloseButton extends StatelessWidget { + const _CloseButton(); + + @override + Widget build(BuildContext context) { + final String tooltip = MaterialLocalizations.of(context).closeButtonTooltip; + + return Semantics( + value: tooltip, + button: true, + excludeSemantics: true, + child: Material( + type: MaterialType.button, + shape: const CircleBorder(), + color: Theme.of(context).primaryColor, + child: InkWell( + customBorder: const CircleBorder(), + onTap: () => Navigator.maybePop(context), + child: Tooltip( + message: tooltip, + child: Container( + width: kToolbarHeight, + height: kToolbarHeight, + alignment: Alignment.center, + child: const Icon( + Icons.close, + color: Colors.white, + ), + ), + ), + ), + ), + ); + } +} + class _QuestionPage extends StatefulWidget { const _QuestionPage({ this.product, @@ -87,24 +125,24 @@ class _QuestionPage extends StatefulWidget { final List? questions; final Function()? updateProductUponAnswers; - bool get shouldDisplayContinueButton => product == null; - @override State<_QuestionPage> createState() => _QuestionPageState(); } class _QuestionPageState extends State<_QuestionPage> with SingleTickerProviderStateMixin, TraceableClientMixin { - final Map _anonymousAnnotationList = + final AnonymousAnnotationList _anonymousAnnotationList = {}; InsightAnnotation? _lastAnswer; static const int _numberQuestionsInit = 3; static const int _numberQuestionsNext = 10; - late Future> _questions; late final QuestionsQuery _questionsQuery; late final LocalDatabase _localDatabase; + + CancelableOperation>? _request; + _RobotoffQuestionState _state = const _RobotoffQuestionLoadingState(); int _currentQuestionIndex = 0; @override @@ -116,91 +154,130 @@ class _QuestionPageState extends State<_QuestionPage> ? ProductQuestionsQuery(widget.product!.barcode!) : RandomQuestionsQuery(); + _loadQuestions(); + } + + Future _loadQuestions({ + Future>? request, + }) async { + _updateState(const _RobotoffQuestionLoadingState()); + final List? widgetQuestions = widget.questions; - if (widgetQuestions != null) { - _questions = Future>.value(widgetQuestions); - } else { - _questions = _questionsQuery.getQuestions( - _localDatabase, - _numberQuestionsInit, + try { + _request?.cancel(); + _request = CancelableOperation>.fromFuture( + request ?? + switch (widgetQuestions) { + null => _questionsQuery.getQuestions( + _localDatabase, + _numberQuestionsInit, + ), + _ => Future>.value(widgetQuestions) + }, ); + + _updateState(_RobotoffQuestionSuccessState(await _request!.value)); + } on Exception catch (err) { + _updateState(_RobotoffQuestionErrorState(err)); + } finally { + _request = null; } } - void _reloadQuestions() { - setState(() { - _questions = _questionsQuery.getQuestions( + void _updateState(_RobotoffQuestionState state) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => setState(() => _state = state), + ); + } + + void _loadNextQuestions() { + _loadQuestions( + request: _questionsQuery.getQuestions( _localDatabase, _numberQuestionsNext, - ); - _currentQuestionIndex = 0; - }); + ), + ); + _currentQuestionIndex = 0; } @override - String get traceTitle => 'robotoff_question_page'; + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async { + final Function()? callback = widget.updateProductUponAnswers; + if (_lastAnswer != null && callback != null) { + await callback(); + } + return true; + }, + child: Center( + child: AnimatedSwitcher( + duration: SmoothAnimationsDuration.medium, + transitionBuilder: (Widget child, Animation animation) { + final Offset animationStartOffset = _getAnimationStartOffset(); + final Animation inAnimation = Tween( + begin: animationStartOffset, + end: Offset.zero, + ).animate(animation); + final Animation outAnimation = Tween( + begin: animationStartOffset.scale(-1, -1), + end: Offset.zero, + ).animate(animation); - @override - String get traceName => 'Opened robotoff_question_page'; + return ClipRect( + child: SlideTransition( + position: child.key == ValueKey(_currentQuestionIndex) + ? // Animate in the new question card. + inAnimation + // Animate out the old question card. + : outAnimation, + child: Padding( + padding: const EdgeInsets.all(SMALL_SPACE), + child: child, + ), + ), + ); + }, + child: KeyedSubtree( + key: ValueKey(_currentQuestionIndex), + child: switch (_state) { + _RobotoffQuestionLoadingState _ => const _LoadingQuestionsView(), + _RobotoffQuestionSuccessState _ => _buildQuestionsWidget(), + _RobotoffQuestionErrorState _ => + _ErrorLoadingView(onRetry: _loadQuestions), + }, + ), + ), + ), + ); + } - @override - Widget build(BuildContext context) => WillPopScope( - onWillPop: () async { - final Function()? callback = widget.updateProductUponAnswers; - if (_lastAnswer != null && callback != null) { - await callback(); - } - return true; - }, - child: Center(child: _buildAnimationSwitcher()), + Widget _buildQuestionsWidget() { + final List questions = + (_state as _RobotoffQuestionSuccessState).questions; + + if (questions.length == _currentQuestionIndex) { + return _QuestionsSuccessView( + onContinue: widget.product == null ? _loadNextQuestions : null, + anonymousAnnotationList: _anonymousAnnotationList, ); + } else { + final RobotoffQuestion question = questions[_currentQuestionIndex]; - AnimatedSwitcher _buildAnimationSwitcher() => AnimatedSwitcher( - duration: SmoothAnimationsDuration.medium, - transitionBuilder: (Widget child, Animation animation) { - final Offset animationStartOffset = _getAnimationStartOffset(); - final Animation inAnimation = Tween( - begin: animationStartOffset, - end: Offset.zero, - ).animate(animation); - final Animation outAnimation = Tween( - begin: animationStartOffset.scale(-1, -1), - end: Offset.zero, - ).animate(animation); - - return ClipRect( - child: SlideTransition( - position: child.key == ValueKey(_currentQuestionIndex) - ? // Animate in the new question card. - inAnimation - // Animate out the old question card. - : outAnimation, - child: Padding( - padding: const EdgeInsets.all(SMALL_SPACE), - child: child, - ), - ), - ); + return _QuestionView( + question: question, + initialProduct: widget.product, + onAnswer: (InsightAnnotation answer) async { + await _saveAnswer(question, answer); + setState(() { + _lastAnswer = answer; + _currentQuestionIndex++; + }); }, - child: Container( - key: ValueKey(_currentQuestionIndex), - child: FutureBuilder>( - future: _questions, - builder: ( - BuildContext context, - AsyncSnapshot> snapshot, - ) => - snapshot.hasData - ? _buildWidget( - context, - questions: snapshot.data!, - questionIndex: _currentQuestionIndex, - ) - : const Center(child: CircularProgressIndicator()), - ), - ), ); + } + } Offset _getAnimationStartOffset() { switch (_lastAnswer) { @@ -217,46 +294,6 @@ class _QuestionPageState extends State<_QuestionPage> } } - Widget _buildWidget( - BuildContext context, { - required List questions, - required int questionIndex, - }) { - if (questions.length == questionIndex) { - return CongratsWidget( - continueButtonLabel: !widget.shouldDisplayContinueButton - ? null - : AppLocalizations.of(context).robotoff_next_n_questions( - _numberQuestionsNext, - ), - anonymousAnnotationList: _anonymousAnnotationList, - onContinue: _reloadQuestions, - ); - } - - final RobotoffQuestion question = questions[questionIndex]; - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - QuestionCard( - question, - initialProduct: widget.product, - ), - QuestionAnswersOptions( - question, - onAnswer: (InsightAnnotation answer) async { - await _saveAnswer(question, answer); - setState(() { - _lastAnswer = answer; - _currentQuestionIndex++; - }); - }, - ), - ], - ); - } - Future _saveAnswer( final RobotoffQuestion question, final InsightAnnotation insightAnnotation, @@ -276,38 +313,182 @@ class _QuestionPageState extends State<_QuestionPage> widget: this, ); } + + @override + void dispose() { + _request?.cancel(); + super.dispose(); + } + + @override + String get traceTitle => 'robotoff_question_page'; + + @override + String get traceName => 'Opened robotoff_question_page'; } -class _CloseButton extends StatelessWidget { - const _CloseButton(); +sealed class _RobotoffQuestionState { + const _RobotoffQuestionState(); +} + +class _RobotoffQuestionLoadingState extends _RobotoffQuestionState { + const _RobotoffQuestionLoadingState(); +} + +class _RobotoffQuestionSuccessState extends _RobotoffQuestionState { + const _RobotoffQuestionSuccessState(this.questions); + + final List questions; +} + +class _RobotoffQuestionErrorState extends _RobotoffQuestionState { + const _RobotoffQuestionErrorState(this.error); + + final Exception error; +} + +class _LoadingQuestionsView extends StatelessWidget { + const _LoadingQuestionsView(); @override Widget build(BuildContext context) { - final String tooltip = MaterialLocalizations.of(context).closeButtonTooltip; + final AppLocalizations appLocalizations = AppLocalizations.of(context); + final double screenHeight = MediaQuery.sizeOf(context).height; - return Semantics( - value: tooltip, - button: true, - excludeSemantics: true, - child: Material( - type: MaterialType.button, - shape: const CircleBorder(), - color: Theme.of(context).primaryColor, - child: InkWell( - customBorder: const CircleBorder(), - onTap: () => Navigator.maybePop(context), - child: Tooltip( - message: tooltip, - child: Container( - width: kToolbarHeight, - height: kToolbarHeight, - alignment: Alignment.center, - child: const Icon( - Icons.close, - color: Colors.white, + return FutureBuilder( + future: Future.delayed(const Duration(milliseconds: 500)), + builder: (BuildContext context, AsyncSnapshot snapshot) { + return AnimatedOpacity( + opacity: snapshot.connectionState == ConnectionState.done ? 1 : 0, + duration: SmoothAnimationsDuration.long, + child: Center( + child: DefaultTextStyle( + textAlign: TextAlign.center, + style: const TextStyle(), + child: FractionallySizedBox( + widthFactor: 0.8, + child: MergeSemantics( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + appLocalizations.hunger_games_loading_line1, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 19.0, + ), + ), + SizedBox(height: screenHeight * 0.05), + const LinearProgressIndicator(), + SizedBox(height: screenHeight * 0.10), + Text( + appLocalizations.hunger_games_loading_line2, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 17.0, + ), + ), + ], + ), + ), ), ), ), + ); + }, + ); + } +} + +class _QuestionView extends StatelessWidget { + const _QuestionView({ + required this.question, + required this.initialProduct, + required this.onAnswer, + }); + + final RobotoffQuestion question; + final Product? initialProduct; + final Function(InsightAnnotation) onAnswer; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + QuestionCard( + question, + initialProduct: initialProduct, + ), + QuestionAnswersOptions( + question, + onAnswer: onAnswer, + ), + ], + ); + } +} + +class _QuestionsSuccessView extends StatelessWidget { + const _QuestionsSuccessView({ + required this.onContinue, + required this.anonymousAnnotationList, + }); + + final VoidCallback? onContinue; + final AnonymousAnnotationList anonymousAnnotationList; + + @override + Widget build(BuildContext context) { + return CongratsWidget( + continueButtonLabel: onContinue != null + ? AppLocalizations.of(context).robotoff_next_n_questions( + _QuestionPageState._numberQuestionsNext, + ) + : null, + anonymousAnnotationList: anonymousAnnotationList, + onContinue: onContinue, + ); + } +} + +class _ErrorLoadingView extends StatelessWidget { + const _ErrorLoadingView({ + required this.onRetry, + }); + + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final AppLocalizations appLocalizations = AppLocalizations.of(context); + + return Center( + child: DefaultTextStyle( + textAlign: TextAlign.center, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 20.0, + ), + child: FractionallySizedBox( + widthFactor: 0.8, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + appLocalizations.hunger_games_error_label, + ), + const SizedBox(height: VERY_LARGE_SPACE), + SmoothLargeButtonWithIcon( + text: appLocalizations.hunger_games_error_retry_button, + icon: Icons.refresh, + onPressed: onRetry, + textStyle: const TextStyle( + fontSize: 18.0, + ), + ), + ], + ), ), ), ); diff --git a/packages/smooth_app/pubspec.yaml b/packages/smooth_app/pubspec.yaml index 72918649d44..cd2c7026cda 100644 --- a/packages/smooth_app/pubspec.yaml +++ b/packages/smooth_app/pubspec.yaml @@ -12,6 +12,7 @@ dependencies: flutter_localizations: sdk: flutter + async: 2.11.0 go_router: 7.0.2 barcode_widget: 2.0.3 carousel_slider: 4.2.1