Created
June 20, 2025 13:01
-
-
Save saturov/75f8b450b344e134a0ef9d46a1c0a3ec to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| ## 1. Ссылки | |
| - 📄 Файл, в который будет сохранён сгенерированный таск-лист: `docs/ai/workflow/02-features/main-screen/task-list.md` | |
| - 📝 Функциональные требования и техническая спецификация: `docs/ai/workflow/02-features/main-screen/business-requirements.md` | |
| - 🖼 Скриншот реализуемого экрана для визуального референса: `docs/ai/workflow/02-features/main-screen/design/main_screen.png` | |
| - 🎨 Макет экрана в Figma: `https://www.figma.com/design/nLDpIUh4gksHkJxWpdx5Jq/AI-Boost?node-id=11-3647&t=WFtpghSDDURrFhNG-4` | |
| - 🗺 Верхнеуровневое описание проекта: `docs/ai/workflow/01-project/overview.md` | |
| - 🏗 Архитектурный гайдлайн: `docs/ai/workflow/01-project/architecture.md` | |
| - 📐 Правила написания кода: `.cursor/rules/combined-rules.mdc` | |
| - 🔌 Swagger API спецификация: `docs/swagger/rigla_swagger.yaml` | |
| ## 2. Бизнес-логика | |
| [ ] 1) Создать файл `lib/feature/main/domain/bloc/main/main_event.dart` и описать события экрана. | |
| - **Действие**: Определить все возможные события для управления состоянием экрана. | |
| - **Код-сниппет**: | |
| ```dart | |
| import 'package:freezed_annotation/freezed_annotation.dart'; | |
| part 'main_event.freezed.dart'; | |
| @freezed | |
| sealed class MainEvent with _$MainEvent { | |
| /// Первичная инициализация экрана | |
| const factory MainEvent.started() = _Started; | |
| /// Запрос на обновление данных через Pull-To-Refresh | |
| const factory MainEvent.refreshRequested() = _RefreshRequested; | |
| } | |
| ``` | |
| [ ] 2) Создать файл `lib/feature/main/domain/bloc/main/main_state.dart` и описать состояния экрана. | |
| - **Действие**: Зафиксировать набор возможных состояний с необходимыми полями данных. | |
| - **Код-сниппет**: | |
| ```dart | |
| import 'package:freezed_annotation/freezed_annotation.dart'; | |
| import 'package:rigla_ai_boost/feature/profile/domain/entity/bonuses_entity.dart'; | |
| import 'package:rigla_ai_boost/feature/profile/domain/entity/delivery_address_entity.dart'; | |
| import 'package:rigla_ai_boost/feature/profile/domain/entity/user_profile_entity.dart'; | |
| import 'package:rigla_ai_boost/feature/main/domain/entity/banner_entity.dart'; | |
| import 'package:rigla_ai_boost/feature/search/domain/entity/symptom_tag_entity.dart'; | |
| import 'package:rigla_ai_boost/feature/showcase/domain/entity/showcase_info_entity.dart'; | |
| part 'main_state.freezed.dart'; | |
| @freezed | |
| sealed class MainState with _$MainState { | |
| /// Начальная загрузка | |
| const factory MainState.loading() = _Loading; | |
| /// Успешно загруженные данные | |
| const factory MainState.data({ | |
| DeliveryAddressEntity? deliveryAddress, | |
| UserProfileEntity? userProfile, | |
| BonusesEntity? bonuses, | |
| List<SymptomTagEntity>? symptomTags, | |
| List<BannerEntity>? banners, | |
| List<ShowcaseInfoEntity>? showcases, | |
| }) = _Data; | |
| /// Ошибка загрузки всех данных | |
| const factory MainState.error() = _Error; | |
| } | |
| ``` | |
| [ ] 3) Создать файл `lib/feature/main/domain/bloc/main/main_bloc.dart` с каркасом бизнес-логики. | |
| - **Действие**: Реализовать класс `MainBloc` без сетевых вызовов, оставить TODO-заглушки. | |
| - **Код-сниппет**: | |
| ```dart | |
| import 'package:flutter_bloc/flutter_bloc.dart'; | |
| import 'main_event.dart'; | |
| import 'main_state.dart'; | |
| import 'package:rigla_ai_boost/feature/profile/data/repository/user_repository.dart'; | |
| import 'package:rigla_ai_boost/feature/main/data/repository/banners_repository.dart'; | |
| import 'package:rigla_ai_boost/feature/recommendations/data/repository/recommendations_repository.dart'; | |
| class MainBloc extends Bloc<MainEvent, MainState> { | |
| MainBloc({ | |
| required UserRepository userRepository, | |
| required BannersRepository bannersRepository, | |
| required RecommendationsRepository recommendationsRepository, | |
| }) : _userRepository = userRepository, | |
| _bannersRepository = bannersRepository, | |
| _recommendationsRepository = recommendationsRepository, | |
| super(const MainState.loading()) { | |
| on<_Started>(_onStarted); | |
| on<_RefreshRequested>(_onRefreshRequested); | |
| } | |
| final UserRepository _userRepository; | |
| final BannersRepository _bannersRepository; | |
| final RecommendationsRepository _recommendationsRepository; | |
| Future<void> _onStarted(_Started event, Emitter<MainState> emit) async { | |
| // TODO: реализовать параллельную загрузку всех данных с использованием кэша | |
| // emit(MainState.data(...)); | |
| } | |
| Future<void> _onRefreshRequested( | |
| _RefreshRequested event, Emitter<MainState> emit) async { | |
| // TODO: реализовать логику Pull-To-Refresh с сохранением предыдущего состояния | |
| } | |
| } | |
| ``` | |
| [ ] 4) Зарегистрировать `MainBloc` в DI-слое. | |
| - **Файл**: `lib/app/di/app_assembly.dart` | |
| - **Действие**: В методе `_registerBlocs()` добавить регистрацию `MainBloc`, прокинув зависимости через `.get`. | |
| - **Код-сниппет**: | |
| ```dart | |
| // ... существующий код ... | |
| void _registerBlocs() { | |
| // ... существующие регистрации ... | |
| mainBloc = reg(() => MainBloc( | |
| userRepository.get, | |
| bannersRepository.get, | |
| recommendationsRepository.get, | |
| )); | |
| } | |
| ``` | |
| ## 3. Data Provisioning | |
| [ ] 5) Создать файл `lib/core/cache/json_cache_service.dart` с минимальным key-value кешом на базе `shared_preferences`. | |
| - **Действие**: Реализовать сервис сохранения/чтения JSON-объектов по ключу. | |
| - **Код-сниппет**: | |
| ```dart | |
| import 'dart:convert'; | |
| import 'package:shared_preferences/shared_preferences.dart'; | |
| class JsonCacheService { | |
| JsonCacheService(this._prefs); | |
| final SharedPreferences _prefs; | |
| Future<void> save(String key, Map<String, dynamic> json) async { | |
| await _prefs.setString(key, jsonEncode(json)); | |
| } | |
| Map<String, dynamic>? read(String key) { | |
| final raw = _prefs.getString(key); | |
| if (raw == null) return null; | |
| return jsonDecode(raw) as Map<String, dynamic>; | |
| } | |
| } | |
| ``` | |
| [ ] 6) Зарегистрировать `JsonCacheService` в DI-слое. | |
| - **Файл**: `lib/app/di/app_assembly.dart` | |
| - **Действие**: В методе `_registerThirdPartyServices()` добавить регистрацию сервиса. | |
| - **Код-сниппет**: | |
| ```dart | |
| // ... существующий код ... | |
| late final Registry<JsonCacheService> jsonCacheService; | |
| void _registerThirdPartyServices() { | |
| // ... существующие регистрации ... | |
| jsonCacheService = reg(() async => | |
| JsonCacheService(await SharedPreferences.getInstance())); | |
| } | |
| ``` | |
| [ ] 7) Обновить `UserRepositoryImpl` для поддержки кеша. | |
| - **Файл**: `lib/feature/profile/data/repository/user_repository_impl.dart` | |
| - **Действие**: Получать `JsonCacheService` через конструктор, читать данные из кеша перед сетевым вызовом и сохранять успешный ответ. | |
| - **Код-сниппет**: | |
| ```dart | |
| class UserRepositoryImpl implements UserRepository { | |
| const UserRepositoryImpl(this._apiClient, this._cache); | |
| final ApiClient _apiClient; | |
| final JsonCacheService _cache; | |
| static const _kProfileKey = 'user_profile'; | |
| @override | |
| Future<UserProfileEntity> getUserProfile() async { | |
| final cached = _cache.read(_kProfileKey); | |
| if (cached != null) { | |
| return UserProfileDto.fromJson(cached).toEntity(); | |
| } | |
| final dto = await _apiClient.getUserProfile(); | |
| _cache.save(_kProfileKey, dto.toJson()); | |
| return dto.toEntity(); | |
| } | |
| // ... аналогично для getUserBonuses() | |
| } | |
| ``` | |
| [ ] 8) Добавить кеширование в `BannersRepositoryImpl`. | |
| - **Файл**: `lib/feature/main/data/repository/banners_repository_impl.dart` | |
| - **Действие**: Аналогично `UserRepositoryImpl`, сохранять и читать список баннеров. | |
| - **Код-сниппет**: | |
| ```dart | |
| const _kBannersKey = 'banners'; | |
| final cached = _cache.read(_kBannersKey); | |
| if (cached != null) { | |
| return (cached['items'] as List) | |
| .map((e) => BannerDto.fromJson(e as Map<String,dynamic>).toEntity()) | |
| .toList(); | |
| } | |
| ``` | |
| [ ] 9) Добавить кеширование в `RecommendationsRepositoryImpl` для методов `getSymptomTags()` и `getShowcases()`. | |
| - **Файлы**: `lib/feature/recommendations/data/repository/recommendations_repository_impl.dart` | |
| - **Действие**: Реализовать хранение по ключам `symptom_tags` и `showcases`. | |
| [ ] 10) Реализовать методы `_loadCachedData()` и `_fetchFreshData()` в `MainBloc`. | |
| - **Файл**: `lib/feature/main/domain/bloc/main/main_bloc.dart` | |
| - **Действие**: | |
| 1. `_loadCachedData()` читает кэш из репозиториев и при наличии эмитит `MainState.data(...)` без состояния загрузки. | |
| 2. `_fetchFreshData()` выполняет параллельные сетевые вызовы через `Future.wait`, обновляет кэш и состояние, обрабатывает ошибки по правилам ТЗ. | |
| - **Код-сниппет**: | |
| ```dart | |
| Future<void> _onStarted(_Started e, Emitter<MainState> emit) async { | |
| await _loadCachedData(emit); | |
| await _fetchFreshData(emit); | |
| } | |
| ``` | |
| [ ] 11) Обновить регистрацию `MainBloc` и репозиториев в `app_assembly.dart` для передачи `JsonCacheService`. | |
| - **Действие**: В методах `_registerRepositories()` и `_registerBlocs()` передавать зависимости через `.get`. | |
| - **Код-сниппет**: | |
| ```dart | |
| userRepository = reg(() => UserRepositoryImpl(apiClient.get, jsonCacheService.get)); | |
| bannersRepository = reg(() => BannersRepositoryImpl(apiClient.get, jsonCacheService.get)); | |
| recommendationsRepository = reg(() => RecommendationsRepositoryImpl(apiClient.get, jsonCacheService.get)); | |
| mainBloc = reg(() => MainBloc( | |
| userRepository.get, | |
| bannersRepository.get, | |
| recommendationsRepository.get, | |
| )); | |
| ``` | |
| ## 4. Сборка UI | |
| [ ] 12) Создать файл `lib/feature/main/presentation/screens/main/main_view_model.dart` и определить интерфейс и реализацию `MainViewModel`. | |
| - **Действие**: Инкапсулировать логику запуска и обновления данных экрана через `MainBloc`. | |
| - **Что будет сделано**: | |
| - Объявить `abstract interface class IMainViewModel` с методами: | |
| - `Stream<MainState> get states;` | |
| - `void init();` | |
| - `Future<void> refresh();` | |
| - Реализовать класс `MainViewModel implements IMainViewModel`, который принимает `MainBloc` через конструктор и проксирует события `MainEvent.started()` и `MainEvent.refreshRequested()`. | |
| - **Код-сниппет**: | |
| ```dart | |
| import 'package:rigla_ai_boost/feature/main/domain/bloc/main/main_bloc.dart'; | |
| import 'package:rigla_ai_boost/feature/main/domain/bloc/main/main_event.dart'; | |
| import 'package:rigla_ai_boost/feature/main/domain/bloc/main/main_state.dart'; | |
| abstract interface class IMainViewModel { | |
| Stream<MainState> get states; | |
| void init(); | |
| Future<void> refresh(); | |
| } | |
| class MainViewModel implements IMainViewModel { | |
| MainViewModel(this._bloc); | |
| final MainBloc _bloc; | |
| @override | |
| Stream<MainState> get states => _bloc.stream; | |
| @override | |
| void init() => _bloc.add(const MainEvent.started()); | |
| @override | |
| Future<void> refresh() async { | |
| _bloc.add(const MainEvent.refreshRequested()); | |
| } | |
| } | |
| ``` | |
| [ ] 13) Создать файл `lib/feature/main/presentation/screens/main/main_screen.dart` и собрать лэйаут экрана. | |
| - **Действие**: Реализовать `MainScreen`, который предоставляет `MainBloc` через `BlocProvider.value` и использует `MainViewModel` для управления состоянием. | |
| - **Что будет сделано**: | |
| - `class MainScreen extends StatefulWidget` и приватный `State`. | |
| - В `initState()` создать `MainViewModel` на основе `context.read<AppAssembly>().mainBloc.get` и вызвать `init()`. | |
| - В `build()` использовать `BlocBuilder<MainBloc, MainState>` для отрисовки состояний `loading | error | data`. | |
| - Для состояния `data` обернуть контент в `RefreshIndicator` и вывести секции в следующем порядке: | |
| 1. `HeadingSection` | |
| 2. `SearchFieldComponent` | |
| 3. `BonusesComponent` | |
| 4. `SymptomSection` | |
| 5. `BannersSection` | |
| 6. `ShowcaseComponent` (для каждой витрины) | |
| - **Код-сниппет**: | |
| ```dart | |
| class MainScreen extends StatefulWidget { | |
| const MainScreen({super.key}); | |
| @override | |
| State<MainScreen> createState() => _MainScreenState(); | |
| } | |
| class _MainScreenState extends State<MainScreen> { | |
| late final MainViewModel _vm; | |
| @override | |
| void initState() { | |
| super.initState(); | |
| final bloc = context.read<AppAssembly>().mainBloc.get; | |
| _vm = MainViewModel(bloc)..init(); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return BlocProvider.value( | |
| value: context.read<AppAssembly>().mainBloc.get, | |
| child: BlocBuilder<MainBloc, MainState>( | |
| builder: (_, state) => state.when( | |
| loading: () => const LoadingComponent(), | |
| error: () => ErrorComponent(onRetry: _vm.refresh), | |
| data: (deliveryAddress, userProfile, bonuses, symptomTags, banners, showcases) { | |
| return RefreshIndicator( | |
| onRefresh: _vm.refresh, | |
| child: ListView( | |
| padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), | |
| children: [ | |
| if (deliveryAddress != null && userProfile != null) | |
| HeadingSection( | |
| deliveryAddress: deliveryAddress, | |
| userProfile: userProfile, | |
| onDeliveryAddressTap: _openDeliveryAddresses, | |
| onProfileTap: _openProfile, | |
| ), | |
| const SizedBox(height: 12), | |
| SearchFieldComponent(onTap: _openSearch), | |
| const SizedBox(height: 12), | |
| if (bonuses != null) | |
| BonusesComponent(entity: bonuses, onTap: _openBonuses), | |
| if (symptomTags?.isNotEmpty ?? false) | |
| SymptomSection(symptoms: symptomTags!, onSymptomTap: _openSymptom), | |
| if (banners?.isNotEmpty ?? false) | |
| BannersSection(banners: banners!), | |
| if (showcases?.isNotEmpty ?? false) | |
| ...showcases!.map((it) => ShowcaseComponent(entity: it, onTap: () => _openShowcase(it))), | |
| ], | |
| ), | |
| ); | |
| }, | |
| ), | |
| ), | |
| ); | |
| } | |
| // TODO: навигационные обработчики | |
| void _openDeliveryAddresses() {} | |
| void _openProfile() {} | |
| void _openSearch() {} | |
| void _openBonuses() {} | |
| void _openSymptom(SymptomTagEntity tag) {} | |
| void _openShowcase(ShowcaseInfoEntity showcase) {} | |
| } | |
| ``` | |
| [ ] 14) Реализовать обработку пользовательских взаимодействий и навигацию внутри `MainScreen`. | |
| - **Файл**: `lib/feature/main/presentation/screens/main/main_screen.dart` | |
| - **Действие**: Заполнить методы `_openDeliveryAddresses`, `_openProfile`, `_openSearch`, `_openBonuses`, `_openSymptom`, `_openShowcase` навигацией через `Navigator.push(...)`, оставив TODO до реализации целевых экранов. | |
| - **Код-сниппет**: | |
| ```dart | |
| void _openSearch() { | |
| // TODO: Navigator.push(context, SearchRoute()); | |
| } | |
| ``` | |
| [ ] 15) Создать файл `lib/feature/main/presentation/screens/main/main_route.dart` и определить класс `MainRoute` для перехода на главный экран. | |
| - **Действие**: Реализовать наследника `MaterialPageRoute`, задав имя `/main` и виджет `MainScreen`. | |
| - **Код-сниппет**: | |
| ```dart | |
| import 'package:flutter/material.dart'; | |
| import 'main_screen.dart'; | |
| class MainRoute extends MaterialPageRoute<void> { | |
| MainRoute() | |
| : super( | |
| settings: const RouteSettings(name: '/main'), | |
| builder: (_) => const MainScreen(), | |
| ); | |
| } | |
| ``` | |
| [ ] 16) Зарегистрировать `MainViewModel` в DI-слое. | |
| - **Файл**: `lib/app/di/app_assembly.dart` | |
| - **Действие**: | |
| 1. Создать метод `_registerViewModels()` (если отсутствует) и добавить регистрацию `MainViewModel`, передавая зависимость `MainBloc` через `.get`. | |
| 2. В конструкторе `AppAssembly` вызвать `_registerViewModels()` после `_registerBlocs()`. | |
| - **Код-сниппет**: | |
| ```dart | |
| // ... существующий код ... | |
| late final Registry<MainViewModel> mainViewModel; | |
| void _registerViewModels() { | |
| mainViewModel = reg(() => MainViewModel(mainBloc.get)); | |
| } | |
| AppAssembly(this.environment) { | |
| _registerConfiguration(); | |
| _registerThirdPartyServices(); | |
| _registerRepositories(); | |
| _registerBlocs(); | |
| _registerViewModels(); | |
| } | |
| ``` | |
| [ ] 17) Обновить `ShowcaseComponent` для поддержки обработки нажатия. | |
| - **Файл**: `lib/feature/main/presentation/components/showcase_component.dart` | |
| - **Действие**: Добавить параметр `VoidCallback onTap` и обернуть содержимое виджета в `ScalePressable` или `GestureDetector`, передавая `onTap`. | |
| - **Код-сниппет**: | |
| ```dart | |
| class ShowcaseComponent extends StatelessWidget { | |
| const ShowcaseComponent({required this.entity, required this.onTap, super.key}); | |
| final ShowcaseInfoEntity entity; | |
| final VoidCallback onTap; | |
| @override | |
| Widget build(BuildContext context) { | |
| return ScalePressable( | |
| onTap: onTap, | |
| child: _ShowcaseBody(entity: entity), | |
| ); | |
| } | |
| } | |
| ``` |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment