From 8710792c4b7681ffdae6ab598057e8874cbe8917 Mon Sep 17 00:00:00 2001 From: Yuri Petrov <48598325+petrovyuri@users.noreply.github.com> Date: Fri, 26 Sep 2025 08:21:42 +0300 Subject: [PATCH] =?UTF-8?q?feat(update):=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8C=20?= =?UTF-8?q?=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?Hard=20&=20Soft=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B9=20(#30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Реализован интерфейс и репозитории для проверки обновлений. 2. Добавлены состояния и кубит для управления процессом обновления. 3. Созданы UI-компоненты для отображения информации об обновлениях. 4. Обновлен README.md с описанием нового модуля и его интеграции --- .vscode/dart.prime_snippets.code-snippets | 20 +++- CHANGELOG.md | 0 lib/app/app.dart | 104 ++++++++++-------- lib/app/depends_providers.dart | 10 ++ lib/di/di_repositories.dart | 27 ++++- .../debug/screens/components_screen.dart | 33 ++++++ lib/features/root/root_screen.dart | 44 +++++++- lib/features/update/README.md | 88 +++++++++++++++ .../repository/update_mock_repository.dart | 43 ++++++++ .../data/repository/update_repository.dart | 23 ++++ .../update/domain/entity/update_entity.dart | 35 ++++++ .../repository/i_update_repository.dart | 16 +++ .../domain/state/cubit/update_cubit.dart | 38 +++++++ .../domain/state/cubit/update_state.dart | 56 ++++++++++ .../components/soft_modal_sheet.dart | 103 +++++++++++++++++ .../screens/hard_update_screen.dart | 42 +++++++ lib/features/update/update_routes.dart | 20 ++++ lib/features/update/update_type.dart | 13 +++ lib/router/app_router.dart | 2 + lib/runner/app_runner.dart | 3 - 20 files changed, 664 insertions(+), 56 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 lib/features/update/README.md create mode 100644 lib/features/update/data/repository/update_mock_repository.dart create mode 100644 lib/features/update/data/repository/update_repository.dart create mode 100644 lib/features/update/domain/entity/update_entity.dart create mode 100644 lib/features/update/domain/repository/i_update_repository.dart create mode 100644 lib/features/update/domain/state/cubit/update_cubit.dart create mode 100644 lib/features/update/domain/state/cubit/update_state.dart create mode 100644 lib/features/update/presentation/components/soft_modal_sheet.dart create mode 100644 lib/features/update/presentation/screens/hard_update_screen.dart create mode 100644 lib/features/update/update_routes.dart create mode 100644 lib/features/update/update_type.dart diff --git a/.vscode/dart.prime_snippets.code-snippets b/.vscode/dart.prime_snippets.code-snippets index ba10b70..5001dee 100644 --- a/.vscode/dart.prime_snippets.code-snippets +++ b/.vscode/dart.prime_snippets.code-snippets @@ -14,5 +14,23 @@ "/// {@macro ${TM_FILENAME_BASE}}" ], "description": "DartDoc короткая запись macro с именем файла" - } + }, + "TODO": { + "prefix": "todo", + "body": [ + "// TODO($1): $2" + ], + "description": "Create todo" + }, + "TRYCATCH": { + "prefix": "tryc", + "body": [ + "try {", + "$1", + "} on Object catch (error,stackTrace) {", + "", + "}", + ], + "description": "Create trycatch" + }, } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e69de29 diff --git a/lib/app/app.dart b/lib/app/app.dart index 932aaa0..4dc4ce5 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,13 +1,16 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:friflex_starter/app/app_context_ext.dart'; import 'package:friflex_starter/app/app_providers.dart'; import 'package:friflex_starter/app/depends_providers.dart'; import 'package:friflex_starter/app/theme/app_theme.dart'; import 'package:friflex_starter/app/theme/theme_notifier.dart'; import 'package:friflex_starter/di/di_container.dart'; - import 'package:friflex_starter/features/error/error_screen.dart'; import 'package:friflex_starter/features/splash/splash_screen.dart'; +import 'package:friflex_starter/features/update/domain/state/cubit/update_cubit.dart'; +import 'package:friflex_starter/features/update/update_type.dart'; +import 'package:friflex_starter/features/update/update_routes.dart'; import 'package:friflex_starter/l10n/gen/app_localizations.dart'; import 'package:friflex_starter/l10n/localization_notifier.dart'; import 'package:go_router/go_router.dart'; @@ -67,44 +70,22 @@ class _AppState extends State { builder: () => FutureBuilder( future: _initFuture, builder: (_, snapshot) { - switch (snapshot.connectionState) { - case ConnectionState.none: - case ConnectionState.waiting: - case ConnectionState.active: - // Пока инициализация показываем Splash - return const SplashScreen(); - case ConnectionState.done: - if (snapshot.hasError) { - return ErrorScreen( - error: snapshot.error, - stackTrace: snapshot.stackTrace, - onRetry: _retryInit, - ); - } - - final diContainer = snapshot.data; - if (diContainer == null) { - return ErrorScreen( - error: - 'Ошибка инициализации зависимостей, diContainer = null', - stackTrace: null, - onRetry: _retryInit, - ); - } - return DependsProviders( - diContainer: diContainer, - child: ThemeConsumer( - builder: () => MediaQuery( - key: ValueKey('prevent_rebuild'), - data: MediaQuery.of(context).copyWith( - textScaler: TextScaler.noScaling, - boldText: false, + return switch (snapshot.connectionState) { + // Если состояние не определено, ожидается или активно, то отображаем экран загрузки + ConnectionState.none || + ConnectionState.waiting || + ConnectionState.active => const SplashScreen(), + ConnectionState.done => + // Если данные получены и не равны null, то отображаем внутренний виджет приложения + // Иначе отображаем экран ошибки + (snapshot.hasData && snapshot.data != null) + ? _App(router: widget.router, diContainer: snapshot.data!) + : ErrorScreen( + error: snapshot.error, + stackTrace: snapshot.stackTrace, + onRetry: _retryInit, ), - child: _App(router: widget.router), - ), - ), - ); - } + }; }, ), ), @@ -128,21 +109,50 @@ class _AppState extends State { /// {@endtemplate} class _App extends StatelessWidget { /// {@macro app_internal} - const _App({required this.router}); + const _App({required this.router, required this.diContainer}); /// Роутер приложения для навигации final GoRouter router; + /// Контейнер зависимостей + final DiContainer diContainer; + @override Widget build(BuildContext context) { - return MaterialApp.router( - routerConfig: router, - darkTheme: AppTheme.dark, - theme: AppTheme.light, - themeMode: context.theme.themeMode, - locale: context.localization.locale, - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, + return DependsProviders( + diContainer: diContainer, + child: BlocConsumer( + listener: (context, state) { + if (state is UpdateSuccessState && + state.updateInfo.updateType == UpdateType.hard && + context.mounted) { + router.goNamed(UpdateRoutes.hardUpdateScreenName); + } + }, + builder: (context, state) { + // Если состояние загрузки, то отображаем экран загрузки + if (state is UpdateLoadingState) { + return const SplashScreen(); + } + return ThemeConsumer( + builder: () => MediaQuery( + key: ValueKey('prevent_rebuild'), + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.noScaling, boldText: false), + child: MaterialApp.router( + routerConfig: router, + darkTheme: AppTheme.dark, + theme: AppTheme.light, + themeMode: context.theme.themeMode, + locale: context.localization.locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + ), + ), + ); + }, + ), ); } } diff --git a/lib/app/depends_providers.dart b/lib/app/depends_providers.dart index d8d5229..01ae33b 100644 --- a/lib/app/depends_providers.dart +++ b/lib/app/depends_providers.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:friflex_starter/di/di_container.dart'; +import 'package:friflex_starter/features/update/domain/state/cubit/update_cubit.dart'; import 'package:provider/provider.dart'; /// Класс для внедрения глобальных зависимостей @@ -19,6 +21,14 @@ final class DependsProviders extends StatelessWidget { providers: [ // Сюда добавляем глобальные блоки, inherited и т.д. Provider.value(value: diContainer), // Передаем контейнер зависимостей + BlocProvider( + create: (_) => UpdateCubit(diContainer.repositories.updatesRepository) + ..checkForUpdates( + versionCode: + '1.0.0', // TODO(yura): заменить на получение из diContainer + platform: 'android', + ), + ), ], child: child, ); diff --git a/lib/di/di_repositories.dart b/lib/di/di_repositories.dart index 5083e74..46e5ea2 100644 --- a/lib/di/di_repositories.dart +++ b/lib/di/di_repositories.dart @@ -11,6 +11,9 @@ import 'package:friflex_starter/features/main/domain/repository/i_main_repositor import 'package:friflex_starter/features/profile/data/repository/profile_mock_repository.dart'; import 'package:friflex_starter/features/profile/data/repository/profile_repository.dart'; import 'package:friflex_starter/features/profile/domain/repository/i_profile_repository.dart'; +import 'package:friflex_starter/features/update/data/repository/update_mock_repository.dart'; +import 'package:friflex_starter/features/update/data/repository/update_repository.dart'; +import 'package:friflex_starter/features/update/domain/repository/i_update_repository.dart'; /// Список названий моковых репозиториев, которые должны быть подменены /// для использования в сборке stage окружения. @@ -23,7 +26,7 @@ import 'package:friflex_starter/features/profile/domain/repository/i_profile_rep /// ``` /// [ AuthCheckRepositoryMock().name, ] /// ``` -final List _mockReposToSwitch = []; +final List _mockReposToSwitch = [UpdateMockRepository().name]; /// {@template di_repositories} /// Класс для инициализации и управления репозиториями приложения. @@ -52,6 +55,9 @@ final class DiRepositories { /// Интерфейс для работы с репозиторием профиля late final IProfileRepository profileRepository; + /// Интерфейс для работы с репозиторием обновлений + late final IUpdateRepository updatesRepository; + /// Метод для инициализации репозиториев в приложении. /// /// Принимает: @@ -68,6 +74,24 @@ final class DiRepositories { required OnError onError, required DiContainer diContainer, }) { + onProgress('Начинаем инициализацию репозиториев...'); + try { + // Инициализация репозитория обновлений + updatesRepository = _lazyInitRepo( + mockFactory: UpdateMockRepository.new, + mainFactory: UpdateRepository.new, + onProgress: onProgress, + environment: diContainer.env, + ); + onProgress(updatesRepository.name); + } on Object catch (error, stackTrace) { + onError( + 'Ошибка инициализации репозитория IUpdateRepository', + error, + stackTrace, + ); + } + try { // Инициализация репозитория авторизации authRepository = _lazyInitRepo( @@ -156,6 +180,7 @@ final class DiRepositories { required T Function() mockFactory, required OnProgress onProgress, }) { + // TODO(yura): https://github.com/smmarty/friflex_flutter_starter/issues/31 - добавить onError final mockRepo = mockFactory(); final mainRepo = mainFactory(); diff --git a/lib/features/debug/screens/components_screen.dart b/lib/features/debug/screens/components_screen.dart index 8ae0514..689db2c 100644 --- a/lib/features/debug/screens/components_screen.dart +++ b/lib/features/debug/screens/components_screen.dart @@ -1,6 +1,12 @@ 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'; +import 'package:friflex_starter/features/update/domain/state/cubit/update_cubit.dart'; +import 'package:friflex_starter/features/update/presentation/components/soft_modal_sheet.dart'; +import 'package:friflex_starter/features/update/update_routes.dart'; +import 'package:friflex_starter/features/update/update_type.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; /// {@template components_screen} /// Экран для демонстрации и тестирования компонентов приложения. @@ -66,6 +72,33 @@ class _ComponentsScreenState extends State { }, child: const Text('Показать снекбар с информацией'), ), + const HBox(16), + ElevatedButton( + onPressed: () { + final updateCubitState = context.read().state; + if (updateCubitState is UpdateSuccessState && + updateCubitState.updateInfo.updateType == UpdateType.soft) { + SoftUpdateModal.show( + context, + updateEntity: updateCubitState.updateInfo, + onUpdate: () { + AppSnackBar.showSuccess( + context: context, + message: 'Начато обновление приложения', + ); + }, + ); + } + }, + child: const Text('Показать модальное окно обновления'), + ), + const HBox(16), + ElevatedButton( + onPressed: () { + context.pushNamed(UpdateRoutes.hardUpdateScreenName); + }, + child: const Text('Переход на экран Hard Update обновления'), + ), ], ), ), diff --git a/lib/features/root/root_screen.dart b/lib/features/root/root_screen.dart index ec3497f..50e1636 100644 --- a/lib/features/root/root_screen.dart +++ b/lib/features/root/root_screen.dart @@ -1,7 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:friflex_starter/app/app_context_ext.dart'; import 'package:friflex_starter/app/app_env.dart'; import 'package:friflex_starter/features/debug/debug_routes.dart'; +import 'package:friflex_starter/features/update/domain/state/cubit/update_cubit.dart'; +import 'package:friflex_starter/features/update/presentation/components/soft_modal_sheet.dart'; +import 'package:friflex_starter/features/update/update_type.dart'; import 'package:go_router/go_router.dart'; /// {@template root_screen} @@ -13,7 +17,7 @@ import 'package:go_router/go_router.dart'; /// - Отображение кнопки отладки в не-продакшн окружениях /// - Интеграцию с GoRouter для навигации /// {@endtemplate} -class RootScreen extends StatelessWidget { +class RootScreen extends StatefulWidget { /// {@macro root_screen} const RootScreen({required this.navigationShell, super.key}); @@ -21,6 +25,38 @@ class RootScreen extends StatelessWidget { /// Содержит информацию о текущем состоянии навигации final StatefulNavigationShell navigationShell; + @override + State createState() => _RootScreenState(); +} + +class _RootScreenState extends State { + @override + void initState() { + super.initState(); + // После построения виджета, проверяем состояние кубита обновлений + // и если есть обновление, то показываем модальное окно + _checkSoftUpdate(); + } + + /// Проверяет состояние кубита обновлений и показывает модальное окно при наличии мягкого обновления + void _checkSoftUpdate() { + WidgetsBinding.instance.addPostFrameCallback((_) { + final updateState = context.read().state; + + // Проверяем только состояние успеха с доступной информацией об обновлении + if (updateState is UpdateSuccessState && + updateState.updateInfo.updateType == UpdateType.soft) { + SoftUpdateModal.show( + context, + updateEntity: updateState.updateInfo, + onUpdate: () { + // TODO(yura): реализовать логику обновления приложения + }, + ); + } + }); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -32,14 +68,14 @@ class RootScreen extends StatelessWidget { }, ) : null, - body: navigationShell, + body: widget.navigationShell, bottomNavigationBar: BottomNavigationBar( items: const [ BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Главная'), BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Профиль'), ], - currentIndex: navigationShell.currentIndex, - onTap: navigationShell.goBranch, + currentIndex: widget.navigationShell.currentIndex, + onTap: widget.navigationShell.goBranch, ), ); } diff --git a/lib/features/update/README.md b/lib/features/update/README.md new file mode 100644 index 0000000..05fb779 --- /dev/null +++ b/lib/features/update/README.md @@ -0,0 +1,88 @@ +# Модуль Hard/Soft Updates + +Модуль для управления обновлениями приложения. Поддерживает мягкие (soft) и обязательные (hard) обновления. + +## Ключевые сущности и состояния + +- **`UpdateEntity`**: доменная сущность с данными об обновлении + - `availableVersion`: доступная версия + - `updateUrl`: ссылка на обновление + - `updateType`: тип (`soft` | `hard`), см. `UpdateType` + - `whatIsNew`: описание изменений + +- **`UpdateType`**: перечисление типов обновления + - `UpdateType.soft` + - `UpdateType.hard` + - `UpdateType.none` + +- **`UpdateCubit`**: управление состоянием проверки обновлений + - Состояния: `UpdateInitialState`, `UpdateLoadingState`, `UpdateSuccessState(UpdateEntity?)`, `UpdateErrorState(message)` + - Метод: `checkForUpdates({required String versionCode, required String platform})` + +## Репозитории + +- **`IUpdateRepository`**: Интерфейс, описывающий методы для проверки обновлений. + - Возвращает `Future` (не может быть `null`) + +- **`UpdateRepository`**: заготовка для реальной интеграции (бэкенд/стор) + - Реализуйте логику в `checkForUpdates` + +- **`UpdateMockRepository`**: мок-реализация для разработки/демо + - Возвращает фиктивное обновление (по умолчанию soft) + +## UI + +- **Soft update** — `SoftUpdateModal` + - BottomSheet с заголовком, списком изменений и кнопками: «Отложить» и «Обновить» + - Статический метод `show` безопасно не откроет модалку, если `updateEntity == null` + +Пример показа модального окна: +```dart +await SoftUpdateModal.show( + context, + updateEntity: updateEntity, // экземпляр UpdateEntity + onUpdate: () { + // TODO: переход в стор/браузер по updateEntity.updateUrl + }, +); +``` + +- **Hard update** — `HardUpdateScreen` + - Блокирующий экран, информирует и не даёт продолжить без обновления + +## Роуты + +- `UpdateRoutes.buildRoutes()` — регистрирует экран hard-обновления по пути `/update` + + +## Структура модуля + +``` +features/update/ +├── data/ +│ └── repository/ +│ ├── update_repository.dart # реализация для интеграции +│ └── update_mock_repository.dart # мок-репозиторий +├── domain/ +│ ├── entity/ +│ │ └── update_entity.dart # доменная сущность +│ ├── repository/ +│ │ └── i_update_repository.dart # контракт репозитория +│ └── state/ +│ └── cubit/ +│ ├── update_cubit.dart # кубит и логика +│ └── update_state.dart # состояния +├── presentation/ +│ ├── components/ +│ │ └── soft_modal_sheet.dart # модалка для soft-обновления +│ └── screens/ +│ └── hard_update_screen.dart # экран для hard-обновления +├── update_type.dart # константы типов обновления +└── update_routes.dart # роут на hard-экран +``` + +## Заметки по реализации + +- Для продакшена реализуйте переход в магазин или браузер по `updateUrl` +- Для soft-обновления не блокируйте UX; для hard — перенаправляйте на блокирующий экран +- Возвращайте `null` из репозитория, если обновлений нет diff --git a/lib/features/update/data/repository/update_mock_repository.dart b/lib/features/update/data/repository/update_mock_repository.dart new file mode 100644 index 0000000..5a279de --- /dev/null +++ b/lib/features/update/data/repository/update_mock_repository.dart @@ -0,0 +1,43 @@ +import 'package:friflex_starter/features/update/domain/entity/update_entity.dart'; +import 'package:friflex_starter/features/update/domain/repository/i_update_repository.dart'; +import 'package:friflex_starter/features/update/update_type.dart'; + +/// Мок обновления обязательное, можно использовать для тестирования +const mockHardUpdateEntity = UpdateEntity( + availableVersion: '2.0.0', + updateUrl: 'https://example.com/update', + updateType: UpdateType.hard, + whatIsNew: 'Добавлены новые функции и исправлены ошибки.', +); + +/// Мок обновления мягкое, можно использовать для тестирования +const mockSoftUpdateEntity = UpdateEntity( + availableVersion: '2.0.0', + updateUrl: 'https://example.com/update', + updateType: UpdateType.soft, + whatIsNew: 'Добавлены новые функции и исправлены ошибки.', +); + +/// {@template UpdateMockRepository} +/// Репозиторий для моковой реализации проверки обновлений +/// {@endtemplate} +final class UpdateMockRepository implements IUpdateRepository { + /// {@macro UpdateMockRepository} + const UpdateMockRepository(); + + @override + Future checkForUpdates({ + required String versionCode, + required String platform, + }) async { + // Имитация задержки для асинхронной операции + await Future.delayed(const Duration(seconds: 1)); + + // Возвращаем фиктивные данные об обновлении + // Можно возвращать [_mockHardUpdateEntity] или [_mockSoftUpdateEntity] + return mockSoftUpdateEntity; + } + + @override + String get name => 'UpdateMockRepository'; +} diff --git a/lib/features/update/data/repository/update_repository.dart b/lib/features/update/data/repository/update_repository.dart new file mode 100644 index 0000000..f20812a --- /dev/null +++ b/lib/features/update/data/repository/update_repository.dart @@ -0,0 +1,23 @@ +import 'package:friflex_starter/features/update/domain/entity/update_entity.dart'; +import 'package:friflex_starter/features/update/domain/repository/i_update_repository.dart'; + +/// {@template UpdateRepository} +/// Репозиторий для реализации проверки обновлений +/// {@endtemplate} +final class UpdateRepository implements IUpdateRepository { + /// {@macro UpdateRepository} + const UpdateRepository(); + + @override + Future checkForUpdates({ + required String versionCode, + required String platform, + }) { + // TODO: Реализовать реальную логику проверки обновлений + // Если обновления нет, возвращаем null + throw UnimplementedError(); + } + + @override + String get name => 'UpdateRepository'; +} diff --git a/lib/features/update/domain/entity/update_entity.dart b/lib/features/update/domain/entity/update_entity.dart new file mode 100644 index 0000000..b73ae84 --- /dev/null +++ b/lib/features/update/domain/entity/update_entity.dart @@ -0,0 +1,35 @@ +import 'package:equatable/equatable.dart'; +import 'package:friflex_starter/features/update/update_type.dart'; + +/// {@template UpdateEntity} +/// Сущность для представления информации об обновлении +/// {@endtemplate} +class UpdateEntity extends Equatable { + /// {@macro UpdateEntity} + const UpdateEntity({ + required this.availableVersion, + required this.updateUrl, + required this.updateType, + required this.whatIsNew, + }); + + /// Доступная версия обновления + final String availableVersion; + + /// URL для загрузки обновления + final String updateUrl; + + /// Тип обновления (например, 'hard' или 'soft', или не требуется) + final UpdateType updateType; + + /// Описание изменений в обновлении + final String whatIsNew; + + @override + List get props => [ + availableVersion, + updateUrl, + updateType, + whatIsNew, + ]; +} diff --git a/lib/features/update/domain/repository/i_update_repository.dart b/lib/features/update/domain/repository/i_update_repository.dart new file mode 100644 index 0000000..39abe4d --- /dev/null +++ b/lib/features/update/domain/repository/i_update_repository.dart @@ -0,0 +1,16 @@ +import 'package:friflex_starter/di/di_base_repo.dart'; +import 'package:friflex_starter/features/update/domain/entity/update_entity.dart'; + +/// {@template IUpdateRepository} +/// Интерфейс репозитория для Hard&Soft обновлений +/// {@endtemplate} +abstract interface class IUpdateRepository with DiBaseRepo { + /// Проверяет наличие обновлений + /// [versionCode] - текущий код версии приложения + /// [platform] - платформа (например, 'android' или 'ios') + /// Возвращает [UpdateEntity] с информацией об обновлении + Future checkForUpdates({ + required String versionCode, + required String platform, + }); +} diff --git a/lib/features/update/domain/state/cubit/update_cubit.dart b/lib/features/update/domain/state/cubit/update_cubit.dart new file mode 100644 index 0000000..d43bc2e --- /dev/null +++ b/lib/features/update/domain/state/cubit/update_cubit.dart @@ -0,0 +1,38 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:friflex_starter/features/update/domain/entity/update_entity.dart'; +import 'package:friflex_starter/features/update/domain/repository/i_update_repository.dart'; + +part 'update_state.dart'; + +/// {@template UpdateCubit} +/// Кубит для управления состояниями обновления приложения +/// {@endtemplate} +class UpdateCubit extends Cubit { + /// {@macro UpdateCubit} + UpdateCubit(this._updatesRepository) : super(UpdateInitialState()); + + /// Репозиторий для проверки обновлений + final IUpdateRepository _updatesRepository; + + /// Метод для проверки доступности обновлений + /// [versionCode] - текущий код версии приложения + /// [platform] - платформа (например, 'android' или 'ios') + Future checkForUpdates({ + required String versionCode, + required String platform, + }) async { + if (state is UpdateLoadingState) return; + emit(UpdateLoadingState()); + try { + final updateInfo = await _updatesRepository.checkForUpdates( + versionCode: versionCode, + platform: platform, + ); + emit(UpdateSuccessState(updateInfo)); + } on Object catch (e, st) { + emit(UpdateErrorState(e.toString())); + addError(e, st); + } + } +} diff --git a/lib/features/update/domain/state/cubit/update_state.dart b/lib/features/update/domain/state/cubit/update_state.dart new file mode 100644 index 0000000..a2fe020 --- /dev/null +++ b/lib/features/update/domain/state/cubit/update_state.dart @@ -0,0 +1,56 @@ +part of 'update_cubit.dart'; + +/// {@template UpdateState} +/// Состояния для управления процессом обновления приложения +/// {@endtemplate} +sealed class UpdateState extends Equatable { + /// {@macro UpdateState} + const UpdateState(); + + @override + List get props => []; +} + +/// {@template UpdateInitialState} +/// Состояние начальной инициализации +/// {@endtemplate} +final class UpdateInitialState extends UpdateState { + /// {@macro UpdateInitialState} + const UpdateInitialState(); +} + +/// {@template UpdateLoadingState} +/// Состояние загрузки информации об обновлении +/// {@endtemplate} +final class UpdateLoadingState extends UpdateState { + /// {@macro UpdateLoadingState} + const UpdateLoadingState(); +} + +/// {@template UpdateSuccessState} +/// Состояние успешного получения информации об обновлении +/// {@endtemplate} +final class UpdateSuccessState extends UpdateState { + /// {@macro UpdateSuccessState} + const UpdateSuccessState(this.updateInfo); + + /// Информация об обновлении + final UpdateEntity updateInfo; + + @override + List get props => [updateInfo]; +} + +/// {@template UpdateErrorState} +/// Состояние ошибки при получении информации об обновлении +/// {@endtemplate} +final class UpdateErrorState extends UpdateState { + /// {@macro UpdateErrorState} + const UpdateErrorState(this.message); + + /// Сообщение об ошибке в UI + final String message; + + @override + List get props => [message]; +} diff --git a/lib/features/update/presentation/components/soft_modal_sheet.dart b/lib/features/update/presentation/components/soft_modal_sheet.dart new file mode 100644 index 0000000..683e085 --- /dev/null +++ b/lib/features/update/presentation/components/soft_modal_sheet.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:friflex_starter/app/ui_kit/app_box.dart'; +import 'package:friflex_starter/features/update/domain/entity/update_entity.dart'; +import 'package:go_router/go_router.dart'; + +/// {@template soft_update_modal} +/// Модальное окно для уведомления о доступности новой версии приложения. +/// +/// Отвечает за: +/// - Отображение информации о новой версии приложения +/// - Предоставление возможности обновления или отложения +/// - Показ описания изменений в новой версии +/// - Мягкое уведомление пользователя без принуждения к обновлению +/// {@endtemplate} +class SoftUpdateModal extends StatelessWidget { + /// {@macro soft_update_modal} + const SoftUpdateModal({required this.updateEntity, this.onUpdate, super.key}); + + /// Информация об обновлении + final UpdateEntity updateEntity; + + /// Обратный вызов при нажатии "Обновить" + final VoidCallback? onUpdate; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Заголовок + Text( + 'Доступна новая версия: ${updateEntity.availableVersion} ', + style: Theme.of( + context, + ).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold), + ), + + const HBox(16), + + // Описание изменений + Text( + 'Что нового:', + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), + ), + const HBox(8), + Text( + updateEntity.whatIsNew, + style: Theme.of(context).textTheme.bodyMedium, + ), + + const HBox(24), + + // Кнопки действий + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () { + context.pop(); + }, + child: const Text('Отложить'), + ), + ), + const WBox(12), + Expanded( + child: ElevatedButton( + onPressed: () { + context.pop(); + onUpdate?.call(); + }, + child: const Text('Обновить'), + ), + ), + ], + ), + ], + ), + ); + } + + /// Показать модальное окно обновления + /// + /// [context] - контекст для отображения модального окна + /// [updateEntity] - информация об обновлении + /// [onUpdate] - функция при нажатии "Обновить" + static Future show( + BuildContext context, { + required UpdateEntity updateEntity, + VoidCallback? onUpdate, + }) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => + SoftUpdateModal(updateEntity: updateEntity, onUpdate: onUpdate), + ); + } +} diff --git a/lib/features/update/presentation/screens/hard_update_screen.dart b/lib/features/update/presentation/screens/hard_update_screen.dart new file mode 100644 index 0000000..d8d7171 --- /dev/null +++ b/lib/features/update/presentation/screens/hard_update_screen.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:friflex_starter/app/ui_kit/app_box.dart'; +import 'package:friflex_starter/features/update/domain/state/cubit/update_cubit.dart'; + +/// Блокирующий экран для обязательного обновления приложения +class HardUpdateScreen extends StatelessWidget { + const HardUpdateScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Hard Обновление')), + body: Center( + child: BlocBuilder( + builder: (context, updateCubitState) { + final updateEntity = updateCubitState is UpdateSuccessState + ? updateCubitState.updateInfo + : null; + return Column( + children: [ + const Text( + 'Доступна новая версия приложения. Пожалуйста, обновите его.', + ), + const HBox(16), + Text( + 'Доступная версия: ${updateEntity?.availableVersion ?? ''}', + ), + const HBox(8), + Text('Что нового: ${updateEntity?.whatIsNew ?? ''}'), + const HBox(8), + Text('Тип обновления: ${updateEntity?.updateType ?? ''}'), + const HBox(8), + Text('URL для обновления: ${updateEntity?.updateUrl ?? ''}'), + ], + ); + }, + ), + ), + ); + } +} diff --git a/lib/features/update/update_routes.dart b/lib/features/update/update_routes.dart new file mode 100644 index 0000000..1123cf2 --- /dev/null +++ b/lib/features/update/update_routes.dart @@ -0,0 +1,20 @@ +import 'package:friflex_starter/features/update/presentation/screens/hard_update_screen.dart'; +import 'package:go_router/go_router.dart'; + +abstract final class UpdateRoutes { + /// Название роута главной страницы + static const String hardUpdateScreenName = 'update_screen'; + + /// Путь роута экрана обновления + static const String _hardUpdateScreenPath = '/update'; + + /// Метод для построения роутов по Update + /// + /// Принимает: + /// - [routes] - вложенные роуты + static GoRoute buildRoutes({List routes = const []}) => GoRoute( + path: _hardUpdateScreenPath, + name: hardUpdateScreenName, + builder: (context, state) => const HardUpdateScreen(), + ); +} diff --git a/lib/features/update/update_type.dart b/lib/features/update/update_type.dart new file mode 100644 index 0000000..7ee53cd --- /dev/null +++ b/lib/features/update/update_type.dart @@ -0,0 +1,13 @@ +/// {@template UpdateType} +/// Тип обновления +/// {@endtemplate} +enum UpdateType { + /// Обязательное обновление + hard, + + /// Мягкое обновление + soft, + + /// Не требуется обновление + none, +} diff --git a/lib/router/app_router.dart b/lib/router/app_router.dart index 8c0d15f..eefd037 100644 --- a/lib/router/app_router.dart +++ b/lib/router/app_router.dart @@ -5,6 +5,7 @@ import 'package:friflex_starter/features/main/presentation/main_routes.dart'; import 'package:friflex_starter/features/profile/presentation/profile_routes.dart'; import 'package:friflex_starter/features/root/root_screen.dart'; import 'package:friflex_starter/features/splash/splash_screen.dart'; +import 'package:friflex_starter/features/update/update_routes.dart'; import 'package:go_router/go_router.dart'; /// {@template app_router} @@ -42,6 +43,7 @@ class AppRouter { path: '/splash', builder: (context, state) => const SplashScreen(), ), + UpdateRoutes.buildRoutes(), ], ); } diff --git a/lib/runner/app_runner.dart b/lib/runner/app_runner.dart index f983349..2f6a84e 100644 --- a/lib/runner/app_runner.dart +++ b/lib/runner/app_runner.dart @@ -125,9 +125,6 @@ class AppRunner { required AppEnv env, required TimerRunner timerRunner, }) async { - // Имитация задержки инициализации - // TODO(yura): Удалить после проверки - await Future.delayed(const Duration(seconds: 3)); debugService.log(() => 'Тип сборки: ${env.name}'); final diContainer = DiContainer(env: env, dService: debugService); await diContainer.init(