From 9ddeb73852d360c40c70f11f97834d6f67c7df42 Mon Sep 17 00:00:00 2001 From: PetrovY Date: Fri, 6 Jun 2025 16:45:40 +0300 Subject: [PATCH] =?UTF-8?q?feat(app):=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=81=D0=BD=D0=B5=D0=BA=D0=B1=D0=B0=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 42 +- .metadata | 12 +- .vscode/settings.json | 5 + lib/app/app_config/app_config.g.dart | 208 ++++--- lib/app/theme/app_colors_scheme.dart | 6 + lib/app/{ => ui_kit}/app_box.dart | 0 lib/app/ui_kit/app_snackbar.dart | 278 ++++++++++ lib/features/debug/debug_routes.dart | 8 + .../debug/screens/components_screen.dart | 52 ++ lib/features/debug/screens/debug_screen.dart | 5 +- lib/features/debug/screens/icons_screen.dart | 2 +- .../presentation/screens/main_screen.dart | 2 +- lib/gen/assets.gen.dart | 49 +- lib/ui_kit/components/app_box.dart | 21 - pubspec.yaml | 2 +- test/components/app_snackbar_test.dart | 524 ++++++++++++++++++ test/widget_test.dart | 1 - 17 files changed, 1013 insertions(+), 204 deletions(-) rename lib/app/{ => ui_kit}/app_box.dart (100%) create mode 100644 lib/app/ui_kit/app_snackbar.dart create mode 100644 lib/features/debug/screens/components_screen.dart delete mode 100644 lib/ui_kit/components/app_box.dart create mode 100644 test/components/app_snackbar_test.dart delete mode 100644 test/widget_test.dart diff --git a/.gitignore b/.gitignore index d38e92b..1cc70fe 100644 --- a/.gitignore +++ b/.gitignore @@ -43,44 +43,4 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release -ios/.gitignore -ios/Podfile -ios/Podfile.lock -ios/Flutter/AppFrameworkInfo.plist -ios/Flutter/Debug.xcconfig -ios/Flutter/Release.xcconfig -ios/Runner/AppDelegate.swift -ios/Runner/Info.plist -ios/Runner/Runner-Bridging-Header.h -ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json -ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png -ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png -ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png -ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png -ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png -ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png -ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png -ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png -ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png -ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png -ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png -ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png -ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png -ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png -ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png -ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json -ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png -ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png -ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png -ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md -ios/Runner/Base.lproj/LaunchScreen.storyboard -ios/Runner/Base.lproj/Main.storyboard -ios/Runner.xcodeproj/project.pbxproj -ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata -ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist -ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings -ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme -ios/Runner.xcworkspace/contents.xcworkspacedata -ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist -ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings -ios/RunnerTests/RunnerTests.swift + diff --git a/.metadata b/.metadata index 9fc6a8a..2b7c01b 100644 --- a/.metadata +++ b/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "ea121f8859e4b13e47a8f845e4586164519588bc" + revision: "be698c48a6750c8cb8e61c740ca9991bb947aba2" channel: "stable" project_type: app @@ -13,11 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: ea121f8859e4b13e47a8f845e4586164519588bc - base_revision: ea121f8859e4b13e47a8f845e4586164519588bc - - platform: web - create_revision: ea121f8859e4b13e47a8f845e4586164519588bc - base_revision: ea121f8859e4b13e47a8f845e4586164519588bc + create_revision: be698c48a6750c8cb8e61c740ca9991bb947aba2 + base_revision: be698c48a6750c8cb8e61c740ca9991bb947aba2 + - platform: android + create_revision: be698c48a6750c8cb8e61c740ca9991bb947aba2 + base_revision: be698c48a6750c8cb8e61c740ca9991bb947aba2 # User provided section diff --git a/.vscode/settings.json b/.vscode/settings.json index ea13bda..39ec3bd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,10 @@ { "cSpell.words": [ + "Снекбар", + "снекбара", + "снекбарами", + "снекбаров", + "cupertino", "unawaited" ] } \ No newline at end of file diff --git a/lib/app/app_config/app_config.g.dart b/lib/app/app_config/app_config.g.dart index 2f1d5dd..15bcb5d 100644 --- a/lib/app/app_config/app_config.g.dart +++ b/lib/app/app_config/app_config.g.dart @@ -13,24 +13,22 @@ final class _Dev { static const String baseUrl = 'https://dev'; static const List _enviedkeysecretKey = [ - 4165345137, - 3493800243, - 502170426, + 1144186709, + 921404830, + 4081271781, ]; static const List _envieddatasecretKey = [ - 4165345045, - 3493800278, - 502170444, + 1144186673, + 921404923, + 4081271699, ]; - static final String secretKey = String.fromCharCodes( - List.generate( - _envieddatasecretKey.length, - (int i) => i, - growable: false, - ).map((int i) => _envieddatasecretKey[i] ^ _enviedkeysecretKey[i]), - ); + static final String secretKey = String.fromCharCodes(List.generate( + _envieddatasecretKey.length, + (int i) => i, + growable: false, + ).map((int i) => _envieddatasecretKey[i] ^ _enviedkeysecretKey[i])); } // coverage:ignore-file @@ -38,64 +36,60 @@ final class _Dev { // generated_from: env/prod.env final class _Prod { static const List _enviedkeybaseUrl = [ - 1959698309, - 1370422491, - 1593239974, - 2980796982, - 3484307801, - 2340951854, - 4002048327, - 2957329110, - 3569108013, - 2324336979, - 691664904, - 2999310215, + 3830062036, + 1024953349, + 2723997296, + 2959773775, + 4255295633, + 114701674, + 1920572043, + 3423730200, + 3647804248, + 2815792265, + 3038381766, + 655048609, ]; static const List _envieddatabaseUrl = [ - 1959698413, - 1370422447, - 1593240018, - 2980796998, - 3484307754, - 2340951828, - 4002048360, - 2957329145, - 3569108061, - 2324336929, - 691664999, - 2999310307, + 3830062012, + 1024953457, + 2723997188, + 2959773759, + 4255295714, + 114701648, + 1920572068, + 3423730231, + 3647804200, + 2815792379, + 3038381737, + 655048645, ]; - static final String baseUrl = String.fromCharCodes( - List.generate( - _envieddatabaseUrl.length, - (int i) => i, - growable: false, - ).map((int i) => _envieddatabaseUrl[i] ^ _enviedkeybaseUrl[i]), - ); + static final String baseUrl = String.fromCharCodes(List.generate( + _envieddatabaseUrl.length, + (int i) => i, + growable: false, + ).map((int i) => _envieddatabaseUrl[i] ^ _enviedkeybaseUrl[i])); static const List _enviedkeysecretKey = [ - 4268709792, - 3715718791, - 3691995036, - 2677812110, + 359753139, + 3208048313, + 1722903860, + 3044498179, ]; static const List _envieddatasecretKey = [ - 4268709840, - 3715718901, - 3691995123, - 2677812202, + 359753155, + 3208048331, + 1722903899, + 3044498279, ]; - static final String secretKey = String.fromCharCodes( - List.generate( - _envieddatasecretKey.length, - (int i) => i, - growable: false, - ).map((int i) => _envieddatasecretKey[i] ^ _enviedkeysecretKey[i]), - ); + static final String secretKey = String.fromCharCodes(List.generate( + _envieddatasecretKey.length, + (int i) => i, + growable: false, + ).map((int i) => _envieddatasecretKey[i] ^ _enviedkeysecretKey[i])); } // coverage:ignore-file @@ -103,66 +97,62 @@ final class _Prod { // generated_from: env/stage.env final class _Stage { static const List _enviedkeybaseUrl = [ - 3824074796, - 1785932277, - 700105518, - 2614901365, - 2850858902, - 3082107206, - 3784178565, - 815141967, - 4277092750, - 3942345021, - 1481955512, - 3678805330, - 206487437, + 2791397647, + 1739207173, + 306419752, + 1371918084, + 4062400488, + 3004897854, + 2820011348, + 1751321626, + 3103957517, + 2168627914, + 1003673382, + 1168070657, + 568662299, ]; static const List _envieddatabaseUrl = [ - 3824074820, - 1785932161, - 700105562, - 2614901253, - 2850858981, - 3082107260, - 3784178602, - 815141984, - 4277092861, - 3942345033, - 1481955545, - 3678805301, - 206487528, + 2791397735, + 1739207281, + 306419804, + 1371918196, + 4062400411, + 3004897796, + 2820011387, + 1751321653, + 3103957630, + 2168627902, + 1003673415, + 1168070758, + 568662398, ]; - static final String baseUrl = String.fromCharCodes( - List.generate( - _envieddatabaseUrl.length, - (int i) => i, - growable: false, - ).map((int i) => _envieddatabaseUrl[i] ^ _enviedkeybaseUrl[i]), - ); + static final String baseUrl = String.fromCharCodes(List.generate( + _envieddatabaseUrl.length, + (int i) => i, + growable: false, + ).map((int i) => _envieddatabaseUrl[i] ^ _enviedkeybaseUrl[i])); static const List _enviedkeysecretKey = [ - 1473916388, - 2313056220, - 2069119783, - 32407210, - 317937387, + 2132342089, + 2579069434, + 3904165526, + 3391356107, + 1192880530, ]; static const List _envieddatasecretKey = [ - 1473916311, - 2313056168, - 2069119814, - 32407245, - 317937294, + 2132342074, + 2579069326, + 3904165623, + 3391356076, + 1192880631, ]; - static final String secretKey = String.fromCharCodes( - List.generate( - _envieddatasecretKey.length, - (int i) => i, - growable: false, - ).map((int i) => _envieddatasecretKey[i] ^ _enviedkeysecretKey[i]), - ); + static final String secretKey = String.fromCharCodes(List.generate( + _envieddatasecretKey.length, + (int i) => i, + growable: false, + ).map((int i) => _envieddatasecretKey[i] ^ _enviedkeysecretKey[i])); } diff --git a/lib/app/theme/app_colors_scheme.dart b/lib/app/theme/app_colors_scheme.dart index 4c0f093..44b80f8 100644 --- a/lib/app/theme/app_colors_scheme.dart +++ b/lib/app/theme/app_colors_scheme.dart @@ -6,4 +6,10 @@ extension AppColorsScheme on ColorScheme { // Тестовый цвет Color get testColor => _isDark ? Colors.green : Colors.red; + + /// Цвет заднего фона снекбара с ошибкой + Color get errorSnackbarBackground => const Color(0xFFD24720); + + /// Цвет заднего фона снекбара с успехом + Color get successSnackbarBackground => const Color(0xFF6FB62C); } diff --git a/lib/app/app_box.dart b/lib/app/ui_kit/app_box.dart similarity index 100% rename from lib/app/app_box.dart rename to lib/app/ui_kit/app_box.dart diff --git a/lib/app/ui_kit/app_snackbar.dart b/lib/app/ui_kit/app_snackbar.dart new file mode 100644 index 0000000..668faa0 --- /dev/null +++ b/lib/app/ui_kit/app_snackbar.dart @@ -0,0 +1,278 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:friflex_starter/app/app_context_ext.dart'; +import 'package:friflex_starter/app/theme/app_colors_scheme.dart'; +import 'package:friflex_starter/app/ui_kit/app_box.dart'; + +/// {@template app_snackbar} +/// Перечисление для типов снекбаров +/// Используется для определения цвета и назначения снекбара +/// {@endtemplate} +enum TypeSnackBar { + /// Снекбар с успехом + success, + + /// Снекбар с ошибкой + error, +} + +/// {@template app_snackbar} +/// Менеджер для управления снекбарами +/// Используется для показа снекбаров в верхней части экрана +/// Имеет статические методы для показа снекбаров с разными типами +/// [showError] - для показа снекбара с ошибкой, +/// [showSuccess] - для показа снекбара с успехом +/// {@endtemplate} +class AppSnackBar extends StatefulWidget { + /// {@macro app_snackbar} + const AppSnackBar._({ + required this.message, + required this.type, + required this.displayDuration, + this.onDismiss, + }); + + /// Сообщение, которое будет отображаться в снекбаре + final String message; + + /// Тип снекбара, определяющий его цвет и назначение + final TypeSnackBar type; + + /// Продолжительность отображения снекбара + final Duration displayDuration; + + /// Функция, вызываемая при закрытии снекбара + final VoidCallback? onDismiss; + + @override + State createState() => _AppSnackBarState(); + + /// Показать снекбар с ошибкой + /// [context] - контекст, в котором будет показан снекбар + /// [message] - сообщение, которое будет отображаться в снекбаре + /// [displayDuration] - продолжительность отображения снекбара + /// По умолчанию 3 секунды + static void showError( + BuildContext context, { + required String message, + Duration displayDuration = const Duration(seconds: 3), + }) { + _show( + context: context, + message: message, + type: TypeSnackBar.error, + displayDuration: displayDuration, + ); + } + + /// Показать снекбар с успехом + /// [context] - контекст, в котором будет показан снекбар + /// [message] - сообщение, которое будет отображаться в снекбаре + /// [displayDuration] - продолжительность отображения снекбара + /// По умолчанию 3 секунды + static void showSuccess({ + required BuildContext context, + required String message, + Duration displayDuration = const Duration(seconds: 3), + }) { + _show( + context: context, + message: message, + type: TypeSnackBar.success, + displayDuration: displayDuration, + ); + } + + /// Приватный метод для показа снекбара + /// Используется статическими методами [showError] и [showSuccess] + static void _show({ + required BuildContext context, + required String message, + required TypeSnackBar type, + required Duration displayDuration, + }) { + // Удаляем предыдущий снекбар + _removeCurrentSnackBar(); + + if (!context.mounted) return; + + final overlay = Overlay.of(context); + final overlayEntry = OverlayEntry( + builder: (context) => AppSnackBar._( + message: message, + type: type, + displayDuration: displayDuration, + onDismiss: _removeCurrentSnackBar, + ), + ); + + _currentSnackBar = overlayEntry; + overlay.insert(overlayEntry); + } + + static OverlayEntry? _currentSnackBar; + + static void _removeCurrentSnackBar() { + _currentSnackBar?.remove(); + _currentSnackBar = null; + } +} + +class _AppSnackBarState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late Animation _slideAnimation; + Timer? _dismissTimer; + bool _isInitialized = false; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_isInitialized) { + _initAnimation(); + _startDismissTimer(); + _isInitialized = true; + } + } + + /// Инициализация анимации для снекбара + /// Используется для определения начальной и конечной позиции снекбара + /// Начальная позиция - за пределами экрана, конечная - 15 пикселей ниже верхнего отступа + void _initAnimation() { + final topPadding = MediaQuery.of(context).padding.top; + // Начальная позиция снекбара - за пределами экрана + final startPosition = -200.0; + // Конечная позиция снекбара - 15 пикселей ниже верхнего отступа + final endPosition = topPadding + 15; + // Создание анимации с использованием Tween + _slideAnimation = Tween(begin: startPosition, end: endPosition) + .animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeOut), + ); + + _animationController.forward(); + } + + /// Запуск таймера для автоматического закрытия снекбара + /// Таймер срабатывает по истечении [widget.displayDuration] + /// и вызывает метод [_dismiss] для закрытия снекбара + void _startDismissTimer() { + _dismissTimer = Timer(widget.displayDuration, _dismiss); + } + + /// Закрытие снекбара + /// Отменяет таймер, если он существует, и запускает обратную анимацию + /// После завершения анимации вызывает функцию [widget.onDismiss], если она задана + /// Если виджет не смонтирован, ничего не делает + void _dismiss() { + if (!mounted) return; + + _dismissTimer?.cancel(); + _animationController.reverse().then((_) { + if (mounted) { + widget.onDismiss?.call(); + } + }); + } + + @override + void dispose() { + _dismissTimer?.cancel(); + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _slideAnimation, + builder: (context, child) { + return Positioned( + top: _slideAnimation.value, + child: Material( + color: Colors.transparent, + child: Center( + child: GestureDetector( + onTap: _dismiss, + behavior: HitTestBehavior.opaque, + child: Container( + constraints: const BoxConstraints(maxWidth: 350), + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: _getBackgroundColor(widget.type), + borderRadius: BorderRadius.circular(16), + ), + padding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 16, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _Icon(type: widget.type), + const WBox(10), + Flexible( + child: Text( + widget.message, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ), + ), + ); + }, + ); + } + + /// Получение цвета фона снекбара в зависимости от его типа + /// Возвращает [Color] в зависимости от типа снекбара + /// [TypeSnackBar.success] - цвет успеха + /// [TypeSnackBar.error] - цвет ошибки + Color _getBackgroundColor(TypeSnackBar type) { + return switch (type) { + TypeSnackBar.success => context.colors.successSnackbarBackground, + TypeSnackBar.error => context.colors.errorSnackbarBackground, + }; + } +} +/// {@template _Icon} +/// Виджет для отображения иконки в снекбаре +/// Используется для отображения иконки в зависимости от типа снекбара +/// {@endtemplate} +class _Icon extends StatelessWidget { + /// {@macro _Icon} + /// Создает экземпляр иконки для снекбара + /// Принимает [type] - тип снекбара, определяющий иконку + /// Используется для отображения иконки успеха или ошибки + const _Icon({required this.type}); + + /// Тип снекбара, определяющий иконку + final TypeSnackBar type; + + @override + Widget build(BuildContext context) { + return switch (type) { + TypeSnackBar.success => Icon( + Icons.check_circle, + color: Colors.white, + size: 32, + ), + TypeSnackBar.error => Icon(Icons.error, color: Colors.white, size: 32), + }; + } +} diff --git a/lib/features/debug/debug_routes.dart b/lib/features/debug/debug_routes.dart index 8674c4b..439681e 100644 --- a/lib/features/debug/debug_routes.dart +++ b/lib/features/debug/debug_routes.dart @@ -1,3 +1,4 @@ +import 'package:friflex_starter/features/debug/screens/components_screen.dart'; import 'package:friflex_starter/features/debug/screens/debug_screen.dart'; import 'package:friflex_starter/features/debug/screens/icons_screen.dart'; import 'package:friflex_starter/features/debug/screens/lang_screen.dart'; @@ -18,6 +19,7 @@ abstract final class DebugRoutes { static const String iconsScreenName = 'icons_screen'; static const String themeScreenName = 'theme_screen'; static const String langScreenName = 'lang_screen'; + static const String componentsScreenName = 'components_screen'; /// Пути к экранам static const String debugScreenPath = '/debug'; @@ -26,6 +28,7 @@ abstract final class DebugRoutes { static const String iconsScreenPath = 'debug/icons'; static const String themeScreenPath = 'debug/theme'; static const String langScreenPath = 'debug/lang'; + static const String componentsScreenPath = 'debug/components'; /// Метод для создания роутов для отладки /// @@ -62,6 +65,11 @@ abstract final class DebugRoutes { name: langScreenName, builder: (context, state) => const LangScreen(), ), + GoRoute( + path: componentsScreenPath, + name: componentsScreenName, + builder: (context, state) => const ComponentsScreen(), + ), ], ); } diff --git a/lib/features/debug/screens/components_screen.dart b/lib/features/debug/screens/components_screen.dart new file mode 100644 index 0000000..a5afb4a --- /dev/null +++ b/lib/features/debug/screens/components_screen.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:friflex_starter/app/ui_kit/app_box.dart'; +import 'package:friflex_starter/app/ui_kit/app_snackbar.dart'; + +/// {@template ComponentsScreen} +/// Экран для демонстрации компонентов приложения. +/// {@endtemplate} +class ComponentsScreen extends StatefulWidget { + /// {@macro ComponentsScreen} + const ComponentsScreen({super.key}); + + @override + State createState() => _ComponentsScreenState(); +} + +class _ComponentsScreenState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Компоненты')), + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const HBox(16), + ElevatedButton( + onPressed: () { + AppSnackBar.showError( + context, + message: + 'Произошла ошибка, это просто длинное сообщение, для проверки, которое занимает 3 строчки', + ); + }, + child: const Text('Показать снекбар с ошибкой'), + ), + const HBox(16), + ElevatedButton( + onPressed: () { + AppSnackBar.showSuccess( + context: context, + message: + 'Все супер, это просто длинное сообщение, для проверки, которое занимает 3 строчки', + ); + }, + child: const Text('Показать снекбар с успехом'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/debug/screens/debug_screen.dart b/lib/features/debug/screens/debug_screen.dart index cbe97b0..47ebb25 100644 --- a/lib/features/debug/screens/debug_screen.dart +++ b/lib/features/debug/screens/debug_screen.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; -import 'package:friflex_starter/app/app_box.dart'; +import 'package:friflex_starter/app/ui_kit/app_box.dart'; import 'package:friflex_starter/app/app_context_ext.dart'; import 'package:friflex_starter/features/debug/debug_routes.dart'; import 'package:go_router/go_router.dart'; +/// {@template debug_screen} +/// Экран для отладки приложения +/// {@endtemplate} class DebugScreen extends StatelessWidget { const DebugScreen({super.key}); diff --git a/lib/features/debug/screens/icons_screen.dart b/lib/features/debug/screens/icons_screen.dart index 826731d..e4e6ed0 100644 --- a/lib/features/debug/screens/icons_screen.dart +++ b/lib/features/debug/screens/icons_screen.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:friflex_starter/app/app_box.dart'; +import 'package:friflex_starter/app/ui_kit/app_box.dart'; import 'package:friflex_starter/gen/assets.gen.dart'; /// {@template IconsScreen} diff --git a/lib/features/main/presentation/screens/main_screen.dart b/lib/features/main/presentation/screens/main_screen.dart index ab65296..6453168 100644 --- a/lib/features/main/presentation/screens/main_screen.dart +++ b/lib/features/main/presentation/screens/main_screen.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:friflex_starter/app/app_box.dart'; +import 'package:friflex_starter/app/ui_kit/app_box.dart'; import 'package:friflex_starter/features/main/presentation/main_routes.dart'; import 'package:go_router/go_router.dart'; diff --git a/lib/gen/assets.gen.dart b/lib/gen/assets.gen.dart index 73a827c..cd0b6e2 100644 --- a/lib/gen/assets.gen.dart +++ b/lib/gen/assets.gen.dart @@ -33,12 +33,12 @@ class $AssetsFontsGen { /// List of all assets List get values => [ - montserratBold, - montserratExtraBold, - montserratMedium, - montserratRegular, - montserratSemiBold, - ]; + montserratBold, + montserratExtraBold, + montserratMedium, + montserratRegular, + montserratSemiBold + ]; } class $AssetsIconsGen { @@ -63,7 +63,7 @@ class $AssetsLottieGen { } class Assets { - const Assets._(); + Assets._(); static const $AssetsFontsGen fonts = $AssetsFontsGen(); static const $AssetsIconsGen icons = $AssetsIconsGen(); @@ -71,11 +71,17 @@ class Assets { } class SvgGenImage { - const SvgGenImage(this._assetName, {this.size, this.flavors = const {}}) - : _isVecFormat = false; + const SvgGenImage( + this._assetName, { + this.size, + this.flavors = const {}, + }) : _isVecFormat = false; - const SvgGenImage.vec(this._assetName, {this.size, this.flavors = const {}}) - : _isVecFormat = true; + const SvgGenImage.vec( + this._assetName, { + this.size, + this.flavors = const {}, + }) : _isVecFormat = true; final String _assetName; final Size? size; @@ -129,8 +135,7 @@ class SvgGenImage { placeholderBuilder: placeholderBuilder, semanticsLabel: semanticsLabel, excludeFromSemantics: excludeFromSemantics, - colorFilter: - colorFilter ?? + colorFilter: colorFilter ?? (color == null ? null : ColorFilter.mode(color, colorBlendMode)), clipBehavior: clipBehavior, cacheColorFilter: cacheColorFilter, @@ -143,7 +148,10 @@ class SvgGenImage { } class LottieGenImage { - const LottieGenImage(this._assetName, {this.flavors = const {}}); + const LottieGenImage( + this._assetName, { + this.flavors = const {}, + }); final String _assetName; final Set flavors; @@ -160,8 +168,11 @@ class LottieGenImage { _lottie.LottieImageProviderFactory? imageProviderFactory, Key? key, AssetBundle? bundle, - Widget Function(BuildContext, Widget, _lottie.LottieComposition?)? - frameBuilder, + Widget Function( + BuildContext, + Widget, + _lottie.LottieComposition?, + )? frameBuilder, ImageErrorWidgetBuilder? errorBuilder, double? width, double? height, @@ -171,9 +182,6 @@ class LottieGenImage { bool? addRepaintBoundary, FilterQuality? filterQuality, void Function(String)? onWarning, - _lottie.LottieDecoder? decoder, - _lottie.RenderCache? renderCache, - bool? backgroundLoading, }) { return _lottie.Lottie.asset( _assetName, @@ -198,9 +206,6 @@ class LottieGenImage { addRepaintBoundary: addRepaintBoundary, filterQuality: filterQuality, onWarning: onWarning, - decoder: decoder, - renderCache: renderCache, - backgroundLoading: backgroundLoading, ); } diff --git a/lib/ui_kit/components/app_box.dart b/lib/ui_kit/components/app_box.dart deleted file mode 100644 index efd74f5..0000000 --- a/lib/ui_kit/components/app_box.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:flutter/widgets.dart'; - -/// {@template HBox} -/// HBox виджет для вертикального отступа (Надстройка над SizedBox) -/// [height] - высота отступа -/// [key] - ключ виджета -/// {@endtemplate} -class HBox extends SizedBox { - /// {@macro HBox} - const HBox(double height, {super.key}) : super(height: height); -} - -/// {@template WBox} -/// WBox виджет для вертикального отступа (Надстройка над SizedBox) -/// [width] - ширина отступа -/// [key] - ключ виджета -/// {@endtemplate} -class WBox extends SizedBox { - /// {@macro WBox} - const WBox(double width, {super.key}) : super(width: width); -} diff --git a/pubspec.yaml b/pubspec.yaml index d84bcf7..54b9be8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,7 +53,7 @@ dev_dependencies: flutter: uses-material-design: true - generate: false + generate: true assets: - assets/icons/ - assets/fonts/ diff --git a/test/components/app_snackbar_test.dart b/test/components/app_snackbar_test.dart new file mode 100644 index 0000000..c76534b --- /dev/null +++ b/test/components/app_snackbar_test.dart @@ -0,0 +1,524 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:friflex_starter/app/ui_kit/app_snackbar.dart'; + +/// Тесты для виджета AppSnackBar +void main() { + group('AppSnackBar Widget Tests', () { + late Widget testApp; + + /// Создание мок-темы с необходимыми цветами для снекбара + ColorScheme createMockColorScheme() { + return const ColorScheme.light().copyWith( + // Добавляем кастомные цвета через extension методы + ); + } + + /// Создание мок-темы с правильными стилями текста + TextTheme createMockTextTheme() { + return const TextTheme(); + } + + setUp(() { + testApp = MaterialApp( + theme: ThemeData( + colorScheme: createMockColorScheme(), + textTheme: createMockTextTheme(), + ), + home: const Scaffold(body: Center(child: Text('Тестовый экран'))), + ); + }); + + testTester(String description, WidgetTesterCallback callback) { + testWidgets(description, (tester) async { + await tester.pumpWidget(testApp); + await callback(tester); + }); + } + + group('AppSnackBar.showError', () { + testTester('показывает снекбар с ошибкой', (tester) async { + const errorMessage = 'Произошла ошибка'; + + // Показываем снекбар с ошибкой + AppSnackBar.showError( + tester.element(find.byType(Scaffold)), + message: errorMessage, + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + // Проверяем, что снекбар отображается + expect(find.byType(AppSnackBar), findsOneWidget); + expect(find.text(errorMessage), findsOneWidget); + + // Проверяем наличие иконки ошибки + expect(find.byType(Icon), findsOneWidget); + }); + + testTester('показывает снекбар с кастомной продолжительностью', ( + tester, + ) async { + const errorMessage = 'Кастомная ошибка'; + const customDuration = Duration(seconds: 5); + + AppSnackBar.showError( + tester.element(find.byType(Scaffold)), + message: errorMessage, + displayDuration: customDuration, + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.byType(AppSnackBar), findsOneWidget); + expect(find.text(errorMessage), findsOneWidget); + }); + + testTester('заменяет предыдущий снекбар при повторном вызове', ( + tester, + ) async { + const firstMessage = 'Первая ошибка'; + const secondMessage = 'Вторая ошибка'; + + // Показываем первый снекбар + AppSnackBar.showError( + tester.element(find.byType(Scaffold)), + message: firstMessage, + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text(firstMessage), findsOneWidget); + + // Показываем второй снекбар + AppSnackBar.showError( + tester.element(find.byType(Scaffold)), + message: secondMessage, + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + // Проверяем, что показывается только второй снекбар + expect(find.text(firstMessage), findsNothing); + expect(find.text(secondMessage), findsOneWidget); + expect(find.byType(AppSnackBar), findsOneWidget); + }); + }); + + group('AppSnackBar.showSuccess', () { + testTester('показывает снекбар с успехом', (tester) async { + const successMessage = 'Операция выполнена успешно'; + + AppSnackBar.showSuccess( + context: tester.element(find.byType(Scaffold)), + message: successMessage, + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.byType(AppSnackBar), findsOneWidget); + expect(find.text(successMessage), findsOneWidget); + expect(find.byType(Icon), findsOneWidget); + }); + + testTester('показывает снекбар с кастомной продолжительностью', ( + tester, + ) async { + const successMessage = 'Кастомный успех'; + const customDuration = Duration(seconds: 2); + + AppSnackBar.showSuccess( + context: tester.element(find.byType(Scaffold)), + message: successMessage, + displayDuration: customDuration, + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.byType(AppSnackBar), findsOneWidget); + expect(find.text(successMessage), findsOneWidget); + }); + }); + + group('AppSnackBar виджет поведение', () { + testTester('показывает анимацию появления', (tester) async { + const message = 'Тестовое сообщение'; + + AppSnackBar.showError( + tester.element(find.byType(Scaffold)), + message: message, + ); + + // Проверяем, что анимация началась + await tester.pump(); + expect(find.byType(AnimatedBuilder), findsAtLeastNWidgets(1)); + + // Ждем завершения анимации + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.byType(AppSnackBar), findsOneWidget); + expect(find.text(message), findsOneWidget); + }); + + testTester('закрывается при тапе', (tester) async { + const message = 'Тап для закрытия'; + + AppSnackBar.showError( + tester.element(find.byType(Scaffold)), + message: message, + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); + + expect(find.byType(AppSnackBar), findsOneWidget); + + // Тапаем по снекбару + await tester.tap(find.byType(GestureDetector)); + await tester.pump(); + + // Ждем завершения анимации закрытия и удаления из Overlay + await tester.pumpAndSettle(); + + // Проверяем, что снекбар исчез + expect(find.byType(AppSnackBar), findsNothing); + }); + + testTester('автоматически закрывается через указанное время', ( + tester, + ) async { + const message = 'Автозакрытие'; + const duration = Duration(milliseconds: 500); + + AppSnackBar.showError( + tester.element(find.byType(Scaffold)), + message: message, + displayDuration: duration, + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); + + expect(find.byType(AppSnackBar), findsOneWidget); + + // Ждем автоматического закрытия + await tester.pump(duration); + await tester.pumpAndSettle(); + + expect(find.byType(AppSnackBar), findsNothing); + }); + + testTester('отображает длинное сообщение с ellipsis', (tester) async { + const longMessage = + 'Это очень длинное сообщение, которое должно отображаться в нескольких строках и обрезаться с помощью ellipsis если оно слишком длинное для отображения в виджете снекбара'; + + AppSnackBar.showError( + tester.element(find.byType(Scaffold)), + message: longMessage, + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.byType(AppSnackBar), findsOneWidget); + + // Проверяем, что текст имеет ограничение по строкам + final textWidget = tester.widget(find.text(longMessage)); + expect(textWidget.maxLines, equals(3)); + expect(textWidget.overflow, equals(TextOverflow.ellipsis)); + }); + + testTester('имеет правильную структуру виджета', (tester) async { + const message = 'Структура'; + + AppSnackBar.showError( + tester.element(find.byType(Scaffold)), + message: message, + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + // Проверяем основную структуру + expect(find.byType(Positioned), findsOneWidget); + expect(find.byType(Material), findsAtLeastNWidgets(1)); + expect(find.byType(GestureDetector), findsOneWidget); + expect(find.byType(Container), findsAtLeastNWidgets(1)); + expect(find.byType(Row), findsOneWidget); + expect(find.byType(Icon), findsOneWidget); + expect(find.byType(Text), findsAtLeastNWidgets(1)); + }); + + testTester('имеет правильные отступы и размеры', (tester) async { + const message = 'Размеры'; + + AppSnackBar.showError( + tester.element(find.byType(Scaffold)), + message: message, + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + // Проверяем ограничения максимальной ширины + final constraintsWidget = tester.widget( + find.descendant( + of: find.byType(GestureDetector), + matching: find.byType(Container), + ), + ); + + expect(constraintsWidget.constraints?.maxWidth, equals(350)); + + // Проверяем отступы + expect( + constraintsWidget.margin, + equals(const EdgeInsets.symmetric(horizontal: 16)), + ); + expect( + constraintsWidget.padding, + equals(const EdgeInsets.symmetric(vertical: 16, horizontal: 16)), + ); + }); + + testTester('имеет правильное скругление углов', (tester) async { + const message = 'Скругление'; + + AppSnackBar.showError( + tester.element(find.byType(Scaffold)), + message: message, + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + final container = tester.widget( + find.descendant( + of: find.byType(GestureDetector), + matching: find.byType(Container), + ), + ); + + final decoration = container.decoration as BoxDecoration; + expect(decoration.borderRadius, equals(BorderRadius.circular(16))); + }); + }); + + group('Управление состоянием', () { + testTester('правильно обрабатывает отсутствие mounted контекста', ( + tester, + ) async { + const message = 'Unmounted контекст'; + + // Создаем виджет, который будет удален + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + return Scaffold( + body: ElevatedButton( + onPressed: () { + AppSnackBar.showError(context, message: message); + }, + child: const Text('Show SnackBar'), + ), + ); + }, + ), + ), + ); + + // Нажимаем кнопку для показа снекбара + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + + // Заменяем весь виджет (контекст становится unmounted) + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: Text('New Screen'))), + ); + + // Проверяем, что ошибки не возникает + expect(tester.takeException(), isNull); + }); + + testTester('правильно очищает ресурсы при dispose', (tester) async { + const message = 'Очистка ресурсов'; + + AppSnackBar.showError( + tester.element(find.byType(Scaffold)), + message: message, + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.byType(AppSnackBar), findsOneWidget); + + // Заменяем весь виджет для вызова dispose + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: Text('New Screen'))), + ); + + // Проверяем, что ошибки не возникает при dispose + expect(tester.takeException(), isNull); + }); + }); + + group('Управление снекбарами', () { + testTester('правильно заменяет предыдущий снекбар новым', (tester) async { + const firstMessage = 'Первый снекбар'; + const secondMessage = 'Второй снекбар'; + + // Показываем первый снекбар + AppSnackBar.showError( + tester.element(find.byType(Scaffold)), + message: firstMessage, + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text(firstMessage), findsOneWidget); + + // Показываем второй снекбар (должен заменить первый) + AppSnackBar.showError( + tester.element(find.byType(Scaffold)), + message: secondMessage, + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + // Проверяем, что остался только второй снекбар + expect(find.text(firstMessage), findsNothing); + expect(find.text(secondMessage), findsOneWidget); + }); + + testTester('правильно заменяет снекбар error на success', (tester) async { + const errorMessage = 'Ошибка'; + const successMessage = 'Успех'; + + // Показываем снекбар с ошибкой + AppSnackBar.showError( + tester.element(find.byType(Scaffold)), + message: errorMessage, + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text(errorMessage), findsOneWidget); + + // Показываем снекбар с успехом + AppSnackBar.showSuccess( + context: tester.element(find.byType(Scaffold)), + message: successMessage, + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + // Проверяем, что показывается только снекбар с успехом + expect(find.text(errorMessage), findsNothing); + expect(find.text(successMessage), findsOneWidget); + }); + }); + + group('Анимация', () { + testTester('анимация появления работает корректно', (tester) async { + const message = 'Анимация появления'; + + AppSnackBar.showError( + tester.element(find.byType(Scaffold)), + message: message, + ); + + // В начале анимации снекбар должен находиться за пределами экрана + await tester.pump(); + + final positionedWidget = tester.widget( + find.byType(Positioned), + ); + expect(positionedWidget.top, lessThan(0)); + + // После завершения анимации снекбар должен быть виден + await tester.pump(const Duration(milliseconds: 300)); + + final positionedWidgetAfter = tester.widget( + find.byType(Positioned), + ); + expect(positionedWidgetAfter.top, greaterThan(0)); + }); + + testTester('анимация скрытия работает корректно при тапе', ( + tester, + ) async { + const message = 'Анимация скрытия'; + + AppSnackBar.showError( + tester.element(find.byType(Scaffold)), + message: message, + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); + + expect(find.byType(AppSnackBar), findsOneWidget); + + // Тапаем для закрытия + await tester.tap(find.byType(GestureDetector)); + await tester.pump(); + + // Анимация закрытия должна начаться + expect(find.byType(AppSnackBar), findsOneWidget); + + // После завершения анимации снекбар должен исчезнуть + await tester.pumpAndSettle(); + expect(find.byType(AppSnackBar), findsNothing); + }); + }); + + group('Интеграция с MediaQuery', () { + testTester('правильно позиционируется с учетом SafeArea', (tester) async { + const message = 'SafeArea test'; + + // Создаем приложение с кастомными отступами + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData( + padding: EdgeInsets.only(top: 50), // Симулируем статус бар + ), + child: const Scaffold(body: Center(child: Text('Test'))), + ), + ), + ); + + AppSnackBar.showError( + tester.element(find.byType(Scaffold)), + message: message, + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); + + // Проверяем, что снекбар отображается (позиция может варьироваться в зависимости от того, + // когда именно MediaQuery применяется) + final positionedWidget = tester.widget( + find.byType(Positioned), + ); + expect( + positionedWidget.top, + greaterThan(0), + ); // Снекбар должен быть видимым + expect(find.byType(AppSnackBar), findsOneWidget); + }); + }); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart deleted file mode 100644 index 8b13789..0000000 --- a/test/widget_test.dart +++ /dev/null @@ -1 +0,0 @@ -