Skip to content

Instantly share code, notes, and snippets.

@saturov
Created June 20, 2025 13:01
Show Gist options
  • Select an option

  • Save saturov/75f8b450b344e134a0ef9d46a1c0a3ec to your computer and use it in GitHub Desktop.

Select an option

Save saturov/75f8b450b344e134a0ef9d46a1c0a3ec to your computer and use it in GitHub Desktop.
## 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