feat(app): Добавить снекбар

This commit is contained in:
PetrovY
2025-06-06 16:45:40 +03:00
parent ff6fe50b2b
commit 9ddeb73852
17 changed files with 1013 additions and 204 deletions

42
.gitignore vendored
View File

@@ -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

View File

@@ -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

View File

@@ -1,5 +1,10 @@
{
"cSpell.words": [
"Снекбар",
"снекбара",
"снекбарами",
"снекбаров",
"cupertino",
"unawaited"
]
}

View File

@@ -13,24 +13,22 @@ final class _Dev {
static const String baseUrl = 'https://dev';
static const List<int> _enviedkeysecretKey = <int>[
4165345137,
3493800243,
502170426,
1144186709,
921404830,
4081271781,
];
static const List<int> _envieddatasecretKey = <int>[
4165345045,
3493800278,
502170444,
1144186673,
921404923,
4081271699,
];
static final String secretKey = String.fromCharCodes(
List<int>.generate(
_envieddatasecretKey.length,
(int i) => i,
growable: false,
).map((int i) => _envieddatasecretKey[i] ^ _enviedkeysecretKey[i]),
);
static final String secretKey = String.fromCharCodes(List<int>.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<int> _enviedkeybaseUrl = <int>[
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<int> _envieddatabaseUrl = <int>[
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<int>.generate(
_envieddatabaseUrl.length,
(int i) => i,
growable: false,
).map((int i) => _envieddatabaseUrl[i] ^ _enviedkeybaseUrl[i]),
);
static final String baseUrl = String.fromCharCodes(List<int>.generate(
_envieddatabaseUrl.length,
(int i) => i,
growable: false,
).map((int i) => _envieddatabaseUrl[i] ^ _enviedkeybaseUrl[i]));
static const List<int> _enviedkeysecretKey = <int>[
4268709792,
3715718791,
3691995036,
2677812110,
359753139,
3208048313,
1722903860,
3044498179,
];
static const List<int> _envieddatasecretKey = <int>[
4268709840,
3715718901,
3691995123,
2677812202,
359753155,
3208048331,
1722903899,
3044498279,
];
static final String secretKey = String.fromCharCodes(
List<int>.generate(
_envieddatasecretKey.length,
(int i) => i,
growable: false,
).map((int i) => _envieddatasecretKey[i] ^ _enviedkeysecretKey[i]),
);
static final String secretKey = String.fromCharCodes(List<int>.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<int> _enviedkeybaseUrl = <int>[
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<int> _envieddatabaseUrl = <int>[
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<int>.generate(
_envieddatabaseUrl.length,
(int i) => i,
growable: false,
).map((int i) => _envieddatabaseUrl[i] ^ _enviedkeybaseUrl[i]),
);
static final String baseUrl = String.fromCharCodes(List<int>.generate(
_envieddatabaseUrl.length,
(int i) => i,
growable: false,
).map((int i) => _envieddatabaseUrl[i] ^ _enviedkeybaseUrl[i]));
static const List<int> _enviedkeysecretKey = <int>[
1473916388,
2313056220,
2069119783,
32407210,
317937387,
2132342089,
2579069434,
3904165526,
3391356107,
1192880530,
];
static const List<int> _envieddatasecretKey = <int>[
1473916311,
2313056168,
2069119814,
32407245,
317937294,
2132342074,
2579069326,
3904165623,
3391356076,
1192880631,
];
static final String secretKey = String.fromCharCodes(
List<int>.generate(
_envieddatasecretKey.length,
(int i) => i,
growable: false,
).map((int i) => _envieddatasecretKey[i] ^ _enviedkeysecretKey[i]),
);
static final String secretKey = String.fromCharCodes(List<int>.generate(
_envieddatasecretKey.length,
(int i) => i,
growable: false,
).map((int i) => _envieddatasecretKey[i] ^ _enviedkeysecretKey[i]));
}

View File

@@ -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);
}

View File

@@ -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<AppSnackBar> 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<AppSnackBar>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _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<double>(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),
};
}
}

View File

@@ -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(),
),
],
);
}

View File

@@ -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<ComponentsScreen> createState() => _ComponentsScreenState();
}
class _ComponentsScreenState extends State<ComponentsScreen> {
@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('Показать снекбар с успехом'),
),
],
),
),
);
}
}

View File

@@ -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});

View File

@@ -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}

View File

@@ -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';

View File

@@ -33,12 +33,12 @@ class $AssetsFontsGen {
/// List of all assets
List<String> 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<String> 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,
);
}

View File

@@ -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);
}

View File

@@ -53,7 +53,7 @@ dev_dependencies:
flutter:
uses-material-design: true
generate: false
generate: true
assets:
- assets/icons/
- assets/fonts/

View File

@@ -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<Text>(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<Container>(
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<Container>(
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<Positioned>(
find.byType(Positioned),
);
expect(positionedWidget.top, lessThan(0));
// После завершения анимации снекбар должен быть виден
await tester.pump(const Duration(milliseconds: 300));
final positionedWidgetAfter = tester.widget<Positioned>(
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<Positioned>(
find.byType(Positioned),
);
expect(
positionedWidget.top,
greaterThan(0),
); // Снекбар должен быть видимым
expect(find.byType(AppSnackBar), findsOneWidget);
});
});
});
}

View File

@@ -1 +0,0 @@