diff --git a/packages/smooth_app/assets/animations/off.riv b/packages/smooth_app/assets/animations/off.riv index c4db28efa9c3..e50fb6faefc7 100644 Binary files a/packages/smooth_app/assets/animations/off.riv and b/packages/smooth_app/assets/animations/off.riv differ diff --git a/packages/smooth_app/lib/l10n/app_en.arb b/packages/smooth_app/lib/l10n/app_en.arb index 844f869d9539..65508f543746 100644 --- a/packages/smooth_app/lib/l10n/app_en.arb +++ b/packages/smooth_app/lib/l10n/app_en.arb @@ -1675,6 +1675,13 @@ } } }, + "product_search_loading_message":"Your search of {search} is in progress.\n\nPlease wait a few seconds…", + "@product_search_loading_message": { + "description": "This message will be displayed when a search is in progress.", + "search": { + "type": "String" + } + }, "user_search_contributor_title": "Products I added", "@user_search_contributor_title": { "description": "User search (contributor): list tile title" diff --git a/packages/smooth_app/lib/main.dart b/packages/smooth_app/lib/main.dart index 357d31c3775c..851446b6ff39 100644 --- a/packages/smooth_app/lib/main.dart +++ b/packages/smooth_app/lib/main.dart @@ -31,6 +31,7 @@ import 'package:smooth_app/helpers/permission_helper.dart'; import 'package:smooth_app/pages/navigator/app_navigator.dart'; import 'package:smooth_app/pages/onboarding/onboarding_flow_navigator.dart'; import 'package:smooth_app/query/product_query.dart'; +import 'package:smooth_app/resources/app_animations.dart'; import 'package:smooth_app/services/smooth_services.dart'; import 'package:smooth_app/themes/color_provider.dart'; import 'package:smooth_app/themes/contrast_provider.dart'; @@ -224,12 +225,14 @@ class _SmoothAppState extends State { provide(_continuousScanModel), provide(_permissionListener), ], - child: AppNavigator( - observers: [ - SentryNavigatorObserver(), - matomoObserver, - ], - child: Builder(builder: _buildApp), + child: AnimationsLoader( + child: AppNavigator( + observers: [ + SentryNavigatorObserver(), + matomoObserver, + ], + child: Builder(builder: _buildApp), + ), ), ); }, diff --git a/packages/smooth_app/lib/pages/product/common/product_query_page.dart b/packages/smooth_app/lib/pages/product/common/product_query_page.dart index 5adaad831ca5..c7941e43717f 100644 --- a/packages/smooth_app/lib/pages/product/common/product_query_page.dart +++ b/packages/smooth_app/lib/pages/product/common/product_query_page.dart @@ -24,9 +24,11 @@ import 'package:smooth_app/pages/personalized_ranking_page.dart'; import 'package:smooth_app/pages/product/common/product_list_item_simple.dart'; import 'package:smooth_app/pages/product/common/product_query_page_helper.dart'; import 'package:smooth_app/query/paged_product_query.dart'; +import 'package:smooth_app/resources/app_animations.dart'; import 'package:smooth_app/widgets/ranking_floating_action_button.dart'; import 'package:smooth_app/widgets/smooth_app_bar.dart'; import 'package:smooth_app/widgets/smooth_scaffold.dart'; +import 'package:smooth_app/widgets/smooth_text.dart'; class ProductQueryPage extends StatefulWidget { const ProductQueryPage({ @@ -110,10 +112,8 @@ class _ProductQueryPageState extends State ); case LoadingStatus.LOADING: if (_model.isEmpty()) { - return _EmptyScreen( - screenSize: screenSize, - name: widget.name, - emptiness: const CircularProgressIndicator.adaptive(), + return _LoadingScreen( + title: widget.name, ); } break; @@ -121,7 +121,6 @@ class _ProductQueryPageState extends State if (_model.isEmpty()) { // TODO(monsieurtanuki): should be tracked as well, shouldn't it? return _EmptyScreen( - screenSize: screenSize, name: widget.name, emptiness: _getEmptyText( themeData, @@ -290,7 +289,6 @@ class _ProductQueryPageState extends State final String errorMessage, ) { return _EmptyScreen( - screenSize: screenSize, name: widget.name, emptiness: Padding( padding: const EdgeInsets.all(SMALL_SPACE), @@ -495,14 +493,12 @@ class _ProductQueryPageState extends State class _EmptyScreen extends StatelessWidget { const _EmptyScreen({ - required this.screenSize, required this.name, required this.emptiness, this.actions, Key? key, }) : super(key: key); - final Size screenSize; final String name; final Widget emptiness; final List? actions; @@ -581,3 +577,38 @@ enum ProductQueryPageResult { editProductQuery, unknown, } + +class _LoadingScreen extends StatelessWidget { + const _LoadingScreen({ + required this.title, + }); + + final String title; + + @override + Widget build(BuildContext context) { + final AppLocalizations appLocalizations = AppLocalizations.of(context); + + return _EmptyScreen( + name: title, + emptiness: FractionallySizedBox( + widthFactor: 0.75, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SearchEyeAnimation( + size: MediaQuery.sizeOf(context).width * 0.2, + ), + const SizedBox(height: VERY_LARGE_SPACE * 2), + TextHighlighter( + text: appLocalizations.product_search_loading_message(title), + filter: title, + softWrap: true, + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } +} diff --git a/packages/smooth_app/lib/resources/app_animations.dart b/packages/smooth_app/lib/resources/app_animations.dart new file mode 100644 index 000000000000..20c781ef8e98 --- /dev/null +++ b/packages/smooth_app/lib/resources/app_animations.dart @@ -0,0 +1,305 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:rive/rive.dart'; +import 'package:smooth_app/services/smooth_services.dart'; + +/// Widget to inject in the hierarchy to have a single instance of the RiveFile +class AnimationsLoader extends StatefulWidget { + const AnimationsLoader({ + required this.child, + super.key, + }); + + final Widget child; + + @override + State createState() => _AnimationsLoaderState(); + + static RiveFile of(BuildContext context) { + return context.read<_AnimationsLoaderState>()._file; + } +} + +class _AnimationsLoaderState extends State { + late final RiveFile _file; + + @override + void initState() { + super.initState(); + preload(); + } + + Future preload() async { + rootBundle.load('assets/animations/off.riv').then( + (ByteData data) async { + // Load the RiveFile from the binary data. + setState(() { + _file = RiveFile.import(data); + }); + }, + onError: (dynamic error) => Logs.e( + 'Unable to load Rive file', + ex: error, + ), + ); + } + + @override + Widget build(BuildContext context) { + return Provider<_AnimationsLoaderState>.value( + value: this, + child: widget.child, + ); + } +} + +class ConsentAnimation extends StatelessWidget { + const ConsentAnimation({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return RiveAnimation.direct( + AnimationsLoader.of(context), + artboard: 'Consent', + animations: const ['Loop'], + ); + } +} + +class DoubleChevronAnimation extends StatefulWidget { + const DoubleChevronAnimation.animate({ + this.size, + super.key, + }) : animated = true; + + const DoubleChevronAnimation.stopped({ + this.size, + super.key, + }) : animated = false; + + final double? size; + final bool animated; + + @override + State createState() => _DoubleChevronAnimationState(); +} + +class _DoubleChevronAnimationState extends State { + StateMachineController? _controller; + + @override + void didUpdateWidget(covariant DoubleChevronAnimation oldWidget) { + super.didUpdateWidget(oldWidget); + _changeAnimation(widget.animated); + } + + @override + Widget build(BuildContext context) { + final double size = widget.size ?? IconTheme.of(context).size ?? 24.0; + + return SizedBox.square( + dimension: size, + child: RiveAnimation.direct( + AnimationsLoader.of(context), + artboard: 'Double chevron', + onInit: (Artboard artboard) { + _controller = StateMachineController.fromArtboard( + artboard, + 'Loop', + ); + + artboard.addController(_controller!); + _changeAnimation(widget.animated); + }, + ), + ); + } + + void _changeAnimation(bool animated) { + final SMIBool toggle = _controller!.findInput('loop')! as SMIBool; + if (toggle.value != animated) { + toggle.value = animated; + } + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } +} + +class SearchEyeAnimation extends StatelessWidget { + const SearchEyeAnimation({ + this.size, + super.key, + }); + + final double? size; + + @override + Widget build(BuildContext context) { + final double size = this.size ?? IconTheme.of(context).size ?? 24.0; + + return SizedBox( + width: size, + height: (80 / 87) * size, + child: RiveAnimation.direct( + AnimationsLoader.of(context), + artboard: 'Search eye', + stateMachines: const ['LoopMachine'], + ), + ); + } +} + +class SearchAnimation extends StatefulWidget { + const SearchAnimation({ + super.key, + this.type = SearchAnimationType.search, + this.size, + }); + + final double? size; + final SearchAnimationType type; + + @override + State createState() => _SearchAnimationState(); +} + +class _SearchAnimationState extends State { + StateMachineController? _controller; + + @override + void didUpdateWidget(SearchAnimation oldWidget) { + super.didUpdateWidget(oldWidget); + _changeAnimation(widget.type); + } + + @override + Widget build(BuildContext context) { + final double size = widget.size ?? IconTheme.of(context).size ?? 24.0; + + return SizedBox.square( + dimension: size, + child: RiveAnimation.direct( + AnimationsLoader.of(context), + artboard: 'Search icon', + onInit: (Artboard artboard) { + _controller = StateMachineController.fromArtboard( + artboard, + 'StateMachine', + ); + + artboard.addController(_controller!); + if (widget.type != SearchAnimationType.search) { + _changeAnimation(widget.type); + } + }, + ), + ); + } + + void _changeAnimation(SearchAnimationType type) { + final SMINumber step = _controller!.findInput('step')! as SMINumber; + step.change(type.step.toDouble()); + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } +} + +enum SearchAnimationType { + search(0), + cancel(1), + edit(2); + + const SearchAnimationType(this.step); + + final int step; +} + +class SunAnimation extends StatelessWidget { + const SunAnimation({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return RiveAnimation.direct( + AnimationsLoader.of(context), + artboard: 'Success', + animations: const ['Timeline 1'], + ); + } +} + +class TorchAnimation extends StatefulWidget { + const TorchAnimation.on({ + this.size, + super.key, + }) : isOn = true; + + const TorchAnimation.off({ + this.size, + super.key, + }) : isOn = false; + + final bool isOn; + final double? size; + + @override + State createState() => _TorchAnimationState(); +} + +class _TorchAnimationState extends State { + StateMachineController? _controller; + + @override + void didUpdateWidget(covariant TorchAnimation oldWidget) { + super.didUpdateWidget(oldWidget); + _changeTorchValue(widget.isOn); + } + + void _changeTorchValue(bool isOn) { + final SMIBool toggle = _controller!.findInput('enable')! as SMIBool; + if (toggle.value != isOn) { + toggle.value = isOn; + } + } + + @override + Widget build(BuildContext context) { + final double size = widget.size ?? IconTheme.of(context).size ?? 24.0; + + return SizedBox.square( + dimension: size, + child: RiveAnimation.asset( + 'assets/animations/off.riv', + artboard: 'Torch', + fit: BoxFit.cover, + onInit: (Artboard artboard) { + _controller = StateMachineController.fromArtboard( + artboard, + 'Switch', + ); + + artboard.addController(_controller!); + _changeTorchValue(widget.isOn); + }, + ), + ); + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } +} diff --git a/packages/smooth_app/lib/widgets/smooth_text.dart b/packages/smooth_app/lib/widgets/smooth_text.dart index 7d41bb1453fe..12820b00cc78 100644 --- a/packages/smooth_app/lib/widgets/smooth_text.dart +++ b/packages/smooth_app/lib/widgets/smooth_text.dart @@ -47,11 +47,15 @@ class TextHighlighter extends StatelessWidget { const TextHighlighter({ required this.text, required this.filter, + this.textAlign, this.selected = false, + this.softWrap = false, }); final String text; final String filter; + final TextAlign? textAlign; + final bool? softWrap; final bool selected; @override @@ -75,7 +79,8 @@ class TextHighlighter extends StatelessWidget { ); }).toList(growable: false), ), - softWrap: false, + softWrap: softWrap, + textAlign: textAlign, overflow: TextOverflow.fade, ); }