mirror of
https://github.com/smmarty/friflex_flutter_starter.git
synced 2025-12-22 01:20:46 +00:00
feat(update): добавить модуль управления Hard & Soft обновлений (#30)
1. Реализован интерфейс и репозитории для проверки обновлений. 2. Добавлены состояния и кубит для управления процессом обновления. 3. Созданы UI-компоненты для отображения информации об обновлениях. 4. Обновлен README.md с описанием нового модуля и его интеграции
This commit is contained in:
20
.vscode/dart.prime_snippets.code-snippets
vendored
20
.vscode/dart.prime_snippets.code-snippets
vendored
@@ -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"
|
||||
},
|
||||
}
|
||||
0
CHANGELOG.md
Normal file
0
CHANGELOG.md
Normal file
104
lib/app/app.dart
104
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<App> {
|
||||
builder: () => FutureBuilder<DiContainer>(
|
||||
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<App> {
|
||||
/// {@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<UpdateCubit, UpdateState>(
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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<String> _mockReposToSwitch = [];
|
||||
final List<String> _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<IUpdateRepository>(
|
||||
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<IAuthRepository>(
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<ComponentsScreen> {
|
||||
},
|
||||
child: const Text('Показать снекбар с информацией'),
|
||||
),
|
||||
const HBox(16),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
final updateCubitState = context.read<UpdateCubit>().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 обновления'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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<RootScreen> createState() => _RootScreenState();
|
||||
}
|
||||
|
||||
class _RootScreenState extends State<RootScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// После построения виджета, проверяем состояние кубита обновлений
|
||||
// и если есть обновление, то показываем модальное окно
|
||||
_checkSoftUpdate();
|
||||
}
|
||||
|
||||
/// Проверяет состояние кубита обновлений и показывает модальное окно при наличии мягкого обновления
|
||||
void _checkSoftUpdate() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final updateState = context.read<UpdateCubit>().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>[
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
88
lib/features/update/README.md
Normal file
88
lib/features/update/README.md
Normal file
@@ -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<UpdateEntity>` (не может быть `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` из репозитория, если обновлений нет
|
||||
@@ -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<UpdateEntity> checkForUpdates({
|
||||
required String versionCode,
|
||||
required String platform,
|
||||
}) async {
|
||||
// Имитация задержки для асинхронной операции
|
||||
await Future<void>.delayed(const Duration(seconds: 1));
|
||||
|
||||
// Возвращаем фиктивные данные об обновлении
|
||||
// Можно возвращать [_mockHardUpdateEntity] или [_mockSoftUpdateEntity]
|
||||
return mockSoftUpdateEntity;
|
||||
}
|
||||
|
||||
@override
|
||||
String get name => 'UpdateMockRepository';
|
||||
}
|
||||
23
lib/features/update/data/repository/update_repository.dart
Normal file
23
lib/features/update/data/repository/update_repository.dart
Normal file
@@ -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<UpdateEntity> checkForUpdates({
|
||||
required String versionCode,
|
||||
required String platform,
|
||||
}) {
|
||||
// TODO: Реализовать реальную логику проверки обновлений
|
||||
// Если обновления нет, возвращаем null
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
String get name => 'UpdateRepository';
|
||||
}
|
||||
35
lib/features/update/domain/entity/update_entity.dart
Normal file
35
lib/features/update/domain/entity/update_entity.dart
Normal file
@@ -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<Object?> get props => [
|
||||
availableVersion,
|
||||
updateUrl,
|
||||
updateType,
|
||||
whatIsNew,
|
||||
];
|
||||
}
|
||||
@@ -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<UpdateEntity> checkForUpdates({
|
||||
required String versionCode,
|
||||
required String platform,
|
||||
});
|
||||
}
|
||||
38
lib/features/update/domain/state/cubit/update_cubit.dart
Normal file
38
lib/features/update/domain/state/cubit/update_cubit.dart
Normal file
@@ -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<UpdateState> {
|
||||
/// {@macro UpdateCubit}
|
||||
UpdateCubit(this._updatesRepository) : super(UpdateInitialState());
|
||||
|
||||
/// Репозиторий для проверки обновлений
|
||||
final IUpdateRepository _updatesRepository;
|
||||
|
||||
/// Метод для проверки доступности обновлений
|
||||
/// [versionCode] - текущий код версии приложения
|
||||
/// [platform] - платформа (например, 'android' или 'ios')
|
||||
Future<void> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
56
lib/features/update/domain/state/cubit/update_state.dart
Normal file
56
lib/features/update/domain/state/cubit/update_state.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
part of 'update_cubit.dart';
|
||||
|
||||
/// {@template UpdateState}
|
||||
/// Состояния для управления процессом обновления приложения
|
||||
/// {@endtemplate}
|
||||
sealed class UpdateState extends Equatable {
|
||||
/// {@macro UpdateState}
|
||||
const UpdateState();
|
||||
|
||||
@override
|
||||
List<Object?> 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<Object?> get props => [updateInfo];
|
||||
}
|
||||
|
||||
/// {@template UpdateErrorState}
|
||||
/// Состояние ошибки при получении информации об обновлении
|
||||
/// {@endtemplate}
|
||||
final class UpdateErrorState extends UpdateState {
|
||||
/// {@macro UpdateErrorState}
|
||||
const UpdateErrorState(this.message);
|
||||
|
||||
/// Сообщение об ошибке в UI
|
||||
final String message;
|
||||
|
||||
@override
|
||||
List<Object> get props => [message];
|
||||
}
|
||||
@@ -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<void> show(
|
||||
BuildContext context, {
|
||||
required UpdateEntity updateEntity,
|
||||
VoidCallback? onUpdate,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) =>
|
||||
SoftUpdateModal(updateEntity: updateEntity, onUpdate: onUpdate),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<UpdateCubit, UpdateState>(
|
||||
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 ?? ''}'),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
20
lib/features/update/update_routes.dart
Normal file
20
lib/features/update/update_routes.dart
Normal file
@@ -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<RouteBase> routes = const []}) => GoRoute(
|
||||
path: _hardUpdateScreenPath,
|
||||
name: hardUpdateScreenName,
|
||||
builder: (context, state) => const HardUpdateScreen(),
|
||||
);
|
||||
}
|
||||
13
lib/features/update/update_type.dart
Normal file
13
lib/features/update/update_type.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
/// {@template UpdateType}
|
||||
/// Тип обновления
|
||||
/// {@endtemplate}
|
||||
enum UpdateType {
|
||||
/// Обязательное обновление
|
||||
hard,
|
||||
|
||||
/// Мягкое обновление
|
||||
soft,
|
||||
|
||||
/// Не требуется обновление
|
||||
none,
|
||||
}
|
||||
@@ -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(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user