Помоги мне с дз по flutter. Вот условие в README....
تم الإنشاء في: ٢٠ أبريل ٢٠٢٦
تمت الإجابة باستخدام GPT-5.4 Thinking بواسطة Chat01
تم الإنشاء في: ٢٠ أبريل ٢٠٢٦
تمت الإجابة باستخدام GPT-5.4 Thinking بواسطة Chat01
Помоги мне с дз по flutter.
Вот условие в README.md:
Стек: ListView.builder · RefreshIndicator · CustomScrollView · SliverAppBar
Дедлайн: пн 27 апреля
bashgit checkout HW01 -- lib/models/ lib/data/mock_data.dart git checkout HW02 -- lib/presentation/launch_detail/
textlib/ ├── models/ ← из HW01 ├── data/ │ ├── launch_repository.dart ← интерфейс (предоставлен) │ ├── mock_data.dart ← из HW01 │ └── mock_launch_repository.dart ← из HW01 + РАСШИРИТЬ ├── utils/ │ └── launch_status_colors.dart ← предоставлен └── presentation/ ├── launch_list/ │ ├── launch_list_screen.dart ← РЕАЛИЗОВАТЬ │ └── launch_card.dart ← РЕАЛИЗОВАТЬ └── launch_detail/ ← из HW02
Добавить реализацию LaunchRepository с поддержкой пагинации:
getLaunches({int page, int pageSize}) — возвращает срез mockLaunches по параметрам.
Виджет карточки одного запуска. Показывает название, агентство, дату, статус.
StatefulWidget с CustomScrollView + SliverAppBar + SliverList:
upcoming-запуска
LaunchListScreen.sliverAppBarNameKeyLaunchListScreen.sliverAppBarCountdownKeyTimer.periodic в initState, отменить в disposeScrollController — при достижении конца грузится следующая страницаLaunchListScreen.emptyStateKeyLaunchDetailScreen через Navigator.pushДата запусков в
mock_data.dartдолжна быть в диапазоне 2026 — иначе тест TEST-3.3 может не найти upcoming.
bashflutter pub get dart run build_runner build --delete-conflicting-outputs flutter test flutter analyze dart format --set-exit-if-changed .
test/hw03_test.dart)| Тест | Описание |
|---|---|
| TEST-3.1 | Лента отображает LaunchCard-виджеты |
| TEST-3.2 | LaunchCard содержит название, агентство, дату |
| TEST-3.3 | SliverAppBar показывает имя и отсчёт ближайшего upcoming |
| TEST-3.4 | RefreshIndicator присутствует и работает |
| TEST-3.5 | pageSize=5 → не более 5 карточек изначально |
| TEST-3.6 | Прокрутка до конца → индикатор загрузки → +5 карточек |
| TEST-3.7 | Конец данных — индикатор загрузки больше не появляется |
| TEST-3.8 | Пустой репозиторий → emptyStateKey виден |
| TEST-3.9 | Тап на карточку → LaunchDetailScreen открывается |
Вот тесты, которые твое решение должно проходить:
text/// Автотесты к ДЗ-3: «Лента запусков» /// /// Запуск: flutter test /// /// Что нужно реализовать: /// lib/models/ + lib/data/ ← скопировать из HW01/HW02 /// lib/data/mock_launch_repository.dart — реализовать LaunchRepository /// lib/presentation/launch_list/launch_list_screen.dart — лента /// lib/presentation/launch_list/launch_card.dart — карточка /// lib/presentation/launch_detail/launch_detail_screen.dart ← из HW02 import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:astrolaunch/data/launch_repository.dart'; import 'package:astrolaunch/models/launch.dart'; import 'package:astrolaunch/presentation/launch_list/launch_card.dart'; import 'package:astrolaunch/presentation/launch_list/launch_list_screen.dart'; // ── Тестовые вспомогательные утилиты ──────────────────────────────────────── /// Inline-реализация репозитория для тестов. class _FakeRepo implements LaunchRepository { final List<Launch> _all; _FakeRepo(this._all); @override Future<List<Launch>> getLaunches({int page = 0, int pageSize = 10}) async { await Future.delayed(const Duration(milliseconds: 50)); final start = page * pageSize; if (start >= _all.length) return []; final end = (start + pageSize).clamp(0, _all.length); return _all.sublist(start, end); } } /// Генератор тестовых запусков. Launch _launch(int i, {LaunchStatus status = LaunchStatus.success}) => Launch( id: 'launch-$i', name: 'Launch $i', date: DateTime.utc(2026, 1, (i % 28) + 1), status: status, rocketId: 'r1', agencyId: 'SpaceX', ); List<Launch> _launches(int count) => List.generate(count, (i) => _launch(i)); List<Launch> _launchesWithUpcoming(int count, {required int upcomingIndex}) => List.generate( count, (i) => _launch( i, status: i == upcomingIndex ? LaunchStatus.upcoming : LaunchStatus.success, ).copyWith( name: i == upcomingIndex ? 'Soyuz MS-28' : 'Launch $i', date: i == upcomingIndex ? DateTime.now().add(const Duration(days: 10)) : DateTime.utc(2026, 1, (i % 28) + 1), ), ); Widget _app(Widget child, {NavigatorObserver? observer}) => MaterialApp( navigatorObservers: [if (observer != null) observer], home: child, ); // ── Observer для проверки навигации ───────────────────────────────────────── class _NavObserver extends NavigatorObserver { bool pushed = false; @override void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) { if (previousRoute != null) pushed = true; } } void main() { // ───────────────────────────────────────────────────────────────────────────── // TEST-3.1: Лента отображает запуски // ───────────────────────────────────────────────────────────────────────────── group('TEST-3.1: Лента отображает запуски', () { testWidgets('первая карточка видна после загрузки', (tester) async { final repo = _FakeRepo(_launches(10)); await tester.pumpWidget(_app(LaunchListScreen(repository: repo))); await tester.pumpAndSettle(); expect( find.byType(LaunchCard), findsWidgets, reason: 'После загрузки в ленте должны быть LaunchCard-виджеты', ); }); testWidgets('количество карточек ≥ 1', (tester) async { final repo = _FakeRepo(_launches(5)); await tester.pumpWidget(_app(LaunchListScreen(repository: repo))); await tester.pumpAndSettle(); expect(find.byType(LaunchCard), findsAtLeastNWidgets(1)); }); }); // ───────────────────────────────────────────────────────────────────────────── // TEST-3.2: Карточка содержит нужные данные // ───────────────────────────────────────────────────────────────────────────── group('TEST-3.2: LaunchCard содержит данные', () { testWidgets('название запуска отображается', (tester) async { final launch = Launch( id: 'test', name: 'Ангара-А5 | Орион', date: DateTime.utc(2026, 3, 15), status: LaunchStatus.upcoming, rocketId: 'rocket-angara', agencyId: 'Роскосмос', ); await tester.pumpWidget(_app( Scaffold(body: LaunchCard(launch: launch)), )); expect( find.textContaining('Ангара-А5'), findsOneWidget, reason: 'LaunchCard должна отображать название запуска', ); }); testWidgets('агентство отображается', (tester) async { final launch = Launch( id: 'test2', name: 'Test', date: DateTime.utc(2026, 5, 1), status: LaunchStatus.success, rocketId: 'r1', agencyId: 'SpaceX', ); await tester.pumpWidget(_app( Scaffold(body: LaunchCard(launch: launch)), )); // Агентство может отображаться как agencyId или как строку expect( find.textContaining('SpaceX'), findsOneWidget, reason: 'LaunchCard должна показывать агентство', ); }); testWidgets('дата запуска отображается (год 2026)', (tester) async { final launch = Launch( id: 'test3', name: 'Test', date: DateTime.utc(2026, 7, 4), status: LaunchStatus.success, rocketId: 'r1', agencyId: 'NASA', ); await tester.pumpWidget(_app( Scaffold(body: LaunchCard(launch: launch)), )); expect( find.byWidgetPredicate((w) { if (w is Text) { final t = w.data ?? ''; return t.contains('2026') || t.contains('07') || t.contains('Jul'); } return false; }), findsWidgets, reason: 'LaunchCard должна показывать дату запуска', ); }); }); // ───────────────────────────────────────────────────────────────────────────── // TEST-3.3: SliverAppBar содержит ближайший запуск // ───────────────────────────────────────────────────────────────────────────── group('TEST-3.3: SliverAppBar — ближайший запуск', () { testWidgets('имя upcoming-запуска видно в SliverAppBar', (tester) async { final launches = _launchesWithUpcoming(10, upcomingIndex: 2); final repo = _FakeRepo(launches); await tester.pumpWidget(_app(LaunchListScreen(repository: repo))); await tester.pumpAndSettle(); expect( find.byKey(LaunchListScreen.sliverAppBarNameKey), findsOneWidget, reason: 'Добавьте Text(key: LaunchListScreen.sliverAppBarNameKey) в SliverAppBar', ); final nameWidget = tester.widget<Text>( find.byKey(LaunchListScreen.sliverAppBarNameKey), ); expect(nameWidget.data, contains('Soyuz MS-28')); }); testWidgets('обратный отсчёт отображается в SliverAppBar', (tester) async { final launches = _launchesWithUpcoming(5, upcomingIndex: 0); final repo = _FakeRepo(launches); await tester.pumpWidget(_app(LaunchListScreen(repository: repo))); await tester.pumpAndSettle(); expect( find.byKey(LaunchListScreen.sliverAppBarCountdownKey), findsOneWidget, reason: 'Добавьте countdown-виджет с key: LaunchListScreen.sliverAppBarCountdownKey', ); }); }); // ───────────────────────────────────────────────────────────────────────────── // TEST-3.4: Pull-to-refresh // ───────────────────────────────────────────────────────────────────────────── group('TEST-3.4: Pull-to-refresh', () { testWidgets('RefreshIndicator присутствует', (tester) async { final repo = _FakeRepo(_launches(5)); await tester.pumpWidget(_app(LaunchListScreen(repository: repo))); await tester.pumpAndSettle(); expect( find.byType(RefreshIndicator), findsOneWidget, reason: 'Оберните список в RefreshIndicator', ); }); testWidgets('после refresh данные не теряются', (tester) async { final repo = _FakeRepo(_launches(5)); await tester.pumpWidget(_app(LaunchListScreen(repository: repo))); await tester.pumpAndSettle(); // Симулируем pull-to-refresh await tester.fling( find.byKey(LaunchListScreen.listKey), const Offset(0, 400), 800, ); await tester.pump(); await tester.pump(const Duration(seconds: 1)); await tester.pumpAndSettle(); expect(find.byType(LaunchCard), findsWidgets); }); }); // ───────────────────────────────────────────────────────────────────────────── // TEST-3.5: Пагинация — первая страница // ───────────────────────────────────────────────────────────────────────────── group('TEST-3.5: Пагинация — первая страница', () { testWidgets('загружает ровно pageSize=5 карточек изначально', (tester) async { final repo = _FakeRepo(_launches(15)); await tester.pumpWidget(_app( LaunchListScreen(repository: repo, pageSize: 5), )); await tester.pumpAndSettle(); // Видимые карточки — не больше первой страницы (5) final cards = tester.widgetList(find.byType(LaunchCard)).toList(); expect( cards.length, lessThanOrEqualTo(5), reason: 'На первой странице должно быть не более 5 карточек (pageSize=5)', ); expect( cards.length, greaterThanOrEqualTo(1), reason: 'Хотя бы одна карточка должна отображаться', ); }); }); // ───────────────────────────────────────────────────────────────────────────── // TEST-3.6: Пагинация — подгрузка следующей страницы // ───────────────────────────────────────────────────────────────────────────── group('TEST-3.6: Пагинация — подгрузка', () { testWidgets('индикатор загрузки появляется при прокрутке вниз', (tester) async { final repo = _FakeRepo(_launches(15)); await tester.pumpWidget(_app( LaunchListScreen(repository: repo, pageSize: 5), )); await tester.pumpAndSettle(); // Скроллим вниз await tester.drag( find.byKey(LaunchListScreen.listKey), const Offset(0, -3000), ); await tester.pump(); // начало прокрутки — триггерит _onScroll // Должен появиться индикатор загрузки await tester.pump(const Duration(milliseconds: 100)); expect( find.byKey(LaunchListScreen.loadingIndicatorKey), findsOneWidget, reason: 'При прокрутке до конца должен появиться CircularProgressIndicator ' 'с key: LaunchListScreen.loadingIndicatorKey', ); // После загрузки карточек становится больше await tester.pumpAndSettle(); final cards = tester.widgetList(find.byType(LaunchCard)).toList(); expect( cards.length, greaterThan(5), reason: 'После подгрузки второй страницы карточек должно быть > 5', ); }); }); // ───────────────────────────────────────────────────────────────────────────── // TEST-3.7: Пагинация — конец данных // ───────────────────────────────────────────────────────────────────────────── group('TEST-3.7: Пагинация — конец данных', () { testWidgets('после загрузки всех данных индикатор не появляется', (tester) async { // 7 запусков, pageSize=5 → страница 0: 5 элементов, страница 1: 2 элемента final repo = _FakeRepo(_launches(7)); await tester.pumpWidget(_app( LaunchListScreen(repository: repo, pageSize: 5), )); await tester.pumpAndSettle(); // Скроллим до конца — загружается страница 1 await tester.drag( find.byKey(LaunchListScreen.listKey), const Offset(0, -5000), ); await tester.pump(); await tester.pumpAndSettle(); // Всего 7 карточек expect( tester.widgetList(find.byType(LaunchCard)).length, equals(7), ); // Скроллим ещё — индикатора загрузки быть не должно await tester.drag( find.byKey(LaunchListScreen.listKey), const Offset(0, -5000), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 200)); expect( find.byKey(LaunchListScreen.loadingIndicatorKey), findsNothing, reason: 'После загрузки всех данных индикатор не должен появляться', ); }); }); // ───────────────────────────────────────────────────────────────────────────── // TEST-3.8: Пустое состояние // ───────────────────────────────────────────────────────────────────────────── group('TEST-3.8: Пустое состояние', () { testWidgets('placeholder с текстом "Запусков не найдено"', (tester) async { final repo = _FakeRepo([]); // пустой репозиторий await tester.pumpWidget(_app(LaunchListScreen(repository: repo))); await tester.pumpAndSettle(); expect( find.byKey(LaunchListScreen.emptyStateKey), findsOneWidget, reason: 'При пустом списке должен показываться виджет с key: LaunchListScreen.emptyStateKey', ); expect( find.textContaining('Запусков не найдено'), findsOneWidget, reason: 'Текст "Запусков не найдено" должен быть виден', ); }); testWidgets('карточек нет при пустом репозитории', (tester) async { final repo = _FakeRepo([]); await tester.pumpWidget(_app(LaunchListScreen(repository: repo))); await tester.pumpAndSettle(); expect(find.byType(LaunchCard), findsNothing); }); }); // ───────────────────────────────────────────────────────────────────────────── // TEST-3.9: Нажатие на карточку — навигация // ───────────────────────────────────────────────────────────────────────────── group('TEST-3.9: Нажатие на карточку', () { testWidgets('тап по карточке выполняет навигацию', (tester) async { final observer = _NavObserver(); final repo = _FakeRepo(_launches(5)); await tester.pumpWidget(_app( LaunchListScreen(repository: repo), observer: observer, )); await tester.pumpAndSettle(); // Тапаем на первую карточку await tester.tap(find.byType(LaunchCard).first); await tester.pumpAndSettle(); expect( observer.pushed, isTrue, reason: 'При нажатии на карточку должна происходить навигация (Navigator.push)', ); }); }); }
А вот файлы с моих прошлых дз:
Agency.dart
textimport 'package:freezed_annotation/freezed_annotation.dart'; part 'agency.freezed.dart'; part 'agency.g.dart'; enum AgencyType { government, commercial, private, educational, } @freezed class Agency with _$Agency { const factory Agency({ required String id, required String name, required AgencyType type, String? country, }) = _Agency; factory Agency.fromJson(Map<String, dynamic> json) => _$AgencyFromJson(json); }
astronaut.dart:
textimport 'package:freezed_annotation/freezed_annotation.dart'; part 'astronaut.freezed.dart'; part 'astronaut.g.dart'; @freezed class Astronaut with _$Astronaut { const factory Astronaut({ required String id, required String name, String? nationality, String? bio, String? imageUrl, }) = _Astronaut; factory Astronaut.fromJson(Map<String, dynamic> json) => _$AstronautFromJson(json); }
launch.dart:
textimport 'package:freezed_annotation/freezed_annotation.dart'; part 'launch.freezed.dart'; part 'launch.g.dart'; @JsonEnum(fieldRename: FieldRename.snake) enum LaunchStatus { upcoming, inFlight, success, failure, partial, } @freezed class Launch with _$Launch { const factory Launch({ required String id, required String name, required DateTime date, required LaunchStatus status, String? description, String? imageUrl, required String rocketId, required String agencyId, }) = _Launch; factory Launch.fromJson(Map<String, dynamic> json) => _$LaunchFromJson(json); }
rocket.dart
textimport 'package:freezed_annotation/freezed_annotation.dart'; part 'rocket.freezed.dart'; part 'rocket.g.dart'; @freezed class Rocket with _$Rocket { const factory Rocket({ required String id, required String name, double? height, double? diameter, double? mass, }) = _Rocket; factory Rocket.fromJson(Map<String, dynamic> json) => _$RocketFromJson(json); }
mock_data.dart:
textimport '../models/launch.dart'; import '../models/rocket.dart'; import '../models/agency.dart'; import '../models/astronaut.dart'; import 'dart:math'; final mockRockets = [ const Rocket(id: 'r1', name: 'Falcon 9'), const Rocket(id: 'r2', name: 'Soyuz'), const Rocket(id: 'r3', name: 'Starship'), ]; final mockAgencies = [ const Agency(id: 'a1', name: 'NASA', type: AgencyType.government), const Agency(id: 'a2', name: 'SpaceX', type: AgencyType.commercial), const Agency(id: 'a3', name: 'ESA', type: AgencyType.government), const Agency(id: 'a4', name: 'Roscosmos', type: AgencyType.government), ]; final mockAstronauts = [ const Astronaut(id: 'as1', name: 'A1'), const Astronaut(id: 'as2', name: 'A2'), const Astronaut(id: 'as3', name: 'A3'), const Astronaut(id: 'as4', name: 'A4'), const Astronaut(id: 'as5', name: 'A5'), ]; final random = Random(); final now = DateTime.now(); final mockLaunches = List.generate(10, (i) { final isUpcoming = random.nextBool(); final date = isUpcoming ? now.add(Duration(days: random.nextInt(30))) : now.subtract(Duration(days: random.nextInt(30))); final status = isUpcoming ? LaunchStatus.upcoming : (random.nextBool() ? LaunchStatus.success : LaunchStatus.failure); return Launch( id: 'l${i + 1}', name: 'Launch ${i + 1}', date: date, status: status, rocketId: mockRockets[random.nextInt(mockRockets.length)].id, agencyId: mockAgencies[random.nextInt(mockAgencies.length)].id, description: random.nextBool() ? 'Mission ${i + 1}' : null, imageUrl: null, ); });
mock_launch_repository.dart:
textimport 'dart:async'; import '../data/mock_data.dart'; import '../models/launch.dart'; class MockLaunchRepository { Future<List<Launch>> getLaunches() async { await Future.delayed(const Duration(milliseconds: 200)); return mockLaunches; } Future<Launch?> getLaunchById(String id) async { await Future.delayed(const Duration(milliseconds: 200)); try { return mockLaunches.firstWhere((e) => e.id == id); } catch (_) { return null; } } }
launch_repository.dart:
textimport '../models/launch.dart'; /// Интерфейс репозитория запусков с поддержкой пагинации. /// /// [MockLaunchRepository] из ДЗ-1 должен реализовать этот интерфейс, /// добавив метод [getLaunches] с параметрами [page] и [pageSize]. /// /// Пример реализации: /// ```dart /// class MockLaunchRepository implements LaunchRepository { /// @override /// Future<List<Launch>> getLaunches({int page = 0, int pageSize = 10}) async { /// await Future.delayed(const Duration(milliseconds: 300)); /// final start = page * pageSize; /// if (start >= mockLaunches.length) return []; /// final end = (start + pageSize).clamp(0, mockLaunches.length); /// return mockLaunches.sublist(start, end); /// } /// } /// ``` abstract interface class LaunchRepository { /// Возвращает страницу запусков. /// [page] — номер страницы (0-based). /// [pageSize] — количество элементов на странице. /// Возвращает пустой список, если страница за пределами данных. Future<List<Launch>> getLaunches({int page = 0, int pageSize = 10}); }
launch_detail_screen.dart
textimport 'dart:async'; import 'package:flutter/material.dart'; import '../../models/agency.dart'; import '../../models/launch.dart'; import '../../models/rocket.dart'; import '../../utils/launch_status_colors.dart'; /// Экран детали запуска. /// /// Требования: /// - Изображение миссии (Image.network / placeholder) /// - Название и дата запуска /// - Статус-бейдж — [Chip] с цветом из [LaunchStatusColors] /// (тексты: "Success" / "Failure" / "Upcoming" / "In Flight" / "Partial") /// - Обратный отсчёт [Timer.periodic] в формате DD:HH:MM:SS, только если /// [launch.status] == [LaunchStatus.upcoming] /// - Описание миссии, информация о ракете и агентстве /// - Lifecycle-логирование: initState, didChangeDependencies, dispose /// (используйте debugPrint) /// /// Keys для тестов — НЕ удалять: /// [statusBadgeKey] — обёртка бейджа статуса /// [countdownKey] — виджет обратного отсчёта (только для upcoming) class LaunchDetailScreen extends StatefulWidget { const LaunchDetailScreen({ super.key, required this.launch, this.rocket, this.agency, }); static const statusBadgeKey = Key('launch_detail_status_badge'); static const countdownKey = Key('launch_detail_countdown'); final Launch launch; final Rocket? rocket; final Agency? agency; @override State<LaunchDetailScreen> createState() => _LaunchDetailScreenState(); } class _LaunchDetailScreenState extends State<LaunchDetailScreen> { Timer? _countdownTimer; Duration _remaining = Duration.zero; @override void initState() { super.initState(); debugPrint('[LaunchDetailScreen] initState — ${widget.launch.name}'); // TODO: Если launch.status == upcoming, вычислить _remaining и запустить // Timer.periodic(Duration(seconds: 1), _onTick) if (widget.launch.status == LaunchStatus.upcoming) { final now = DateTime.now(); final diff = widget.launch.date.difference(now); _remaining = diff.isNegative ? Duration.zero : diff; _countdownTimer = Timer.periodic(const Duration(seconds: 1), _onTick); } } @override void didChangeDependencies() { super.didChangeDependencies(); debugPrint('[LaunchDetailScreen] didChangeDependencies'); // TODO: при необходимости — реагируйте на смену InheritedWidget } // ignore: unused_element void _onTick(Timer _) { // TODO: пересчитать _remaining, вызвать setState // Не забудьте: если _remaining <= 0, отменить таймер final now = DateTime.now(); final diff = widget.launch.date.difference(now); if (diff.isNegative) { _countdownTimer?.cancel(); setState(() => _remaining = Duration.zero); return; } setState(() => _remaining = diff); } @override void dispose() { _countdownTimer?.cancel(); debugPrint('[LaunchDetailScreen] dispose — таймер остановлен'); super.dispose(); } @override Widget build(BuildContext context) { // TODO: Реализуйте UI согласно ТЗ. // // Подсказка по структуре: // Scaffold // AppBar(title: Text(widget.launch.name)) // SingleChildScrollView // Column // _MissionImage(imageUrl: widget.launch.imageUrl) // _StatusBadge(status: widget.launch.status) ← key: statusBadgeKey // if upcoming → _Countdown(remaining: _remaining) ← key: countdownKey // _LaunchInfo(launch: widget.launch) // if rocket != null → _RocketInfo(rocket: widget.rocket!) // if agency != null → _AgencyInfo(agency: widget.agency!) return Scaffold( appBar: AppBar(title: Text(widget.launch.name)), body: Center( child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ _MissionImage(imageUrl: widget.launch.imageUrl), StatusBadge( key: LaunchDetailScreen.statusBadgeKey, status: widget.launch.status, ), if (widget.launch.status == LaunchStatus.upcoming) CountdownWidget( key: LaunchDetailScreen.countdownKey, remaining: _remaining, ), _LaunchInfo(launch: widget.launch), if (widget.rocket != null) _RocketInfo(rocket: widget.rocket!), if (widget.agency != null) _AgencyInfo(agency: widget.agency!), ], ), ), ), ); } } // --------------------------------------------------------------------------- // Вспомогательные виджеты (реализуйте каждый в отдельном классе ниже) // --------------------------------------------------------------------------- /// Статус-бейдж. Используйте [Chip] с [backgroundColor] из [LaunchStatusColors]. /// Текст: "Success" / "Failure" / "Upcoming" / "In Flight" / "Partial" class StatusBadge extends StatelessWidget { const StatusBadge({super.key, required this.status}); final LaunchStatus status; String get _label => switch (status) { LaunchStatus.success => 'Success', LaunchStatus.failure => 'Failure', LaunchStatus.upcoming => 'Upcoming', LaunchStatus.inFlight => 'In Flight', LaunchStatus.partial => 'Partial', }; @override Widget build(BuildContext context) { // TODO: вернуть Chip( // label: Text(_label), // backgroundColor: LaunchStatusColors.forStatus(status), // ) return Chip( label: Text(_label), backgroundColor: LaunchStatusColors.forStatus(status), ); } } /// Обратный отсчёт. Формат: DD:HH:MM:SS (с нулями, например "01:23:45:07") class CountdownWidget extends StatelessWidget { const CountdownWidget({super.key, required this.remaining}); final Duration remaining; String get _formatted { // TODO: реализуйте форматирование // Подсказка: // final days = remaining.inDays; // final hours = remaining.inHours.remainder(24); // final minutes = remaining.inMinutes.remainder(60); // final seconds = remaining.inSeconds.remainder(60); final days = remaining.inDays.toString().padLeft(2, '0'); final hours = (remaining.inHours % 24).toString().padLeft(2, '0'); final minutes = (remaining.inMinutes % 60).toString().padLeft(2, '0'); final seconds = (remaining.inSeconds % 60).toString().padLeft(2, '0'); return '$days:$hours:$minutes:$seconds'; } @override Widget build(BuildContext context) { return Text(_formatted, style: Theme.of(context).textTheme.headlineMedium); } } class _MissionImage extends StatelessWidget { const _MissionImage({this.imageUrl}); final String? imageUrl; @override Widget build(BuildContext context) { if (imageUrl == null || imageUrl!.isEmpty) { return const SizedBox( height: 180, width: double.infinity, child: Center(child: Icon(Icons.image_not_supported_outlined)), ); } return ClipRRect( borderRadius: BorderRadius.circular(12), child: SizedBox( height: 180, width: double.infinity, child: Image.network( imageUrl!, fit: BoxFit.cover, ), ), ); } } class _LaunchInfo extends StatelessWidget { const _LaunchInfo({required this.launch}); final Launch launch; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(launch.date.toString()), ], ); } } class _RocketInfo extends StatelessWidget { const _RocketInfo({required this.rocket}); final Rocket rocket; @override Widget build(BuildContext context) { return Text('Rocket: ${rocket.name}'); } } class _AgencyInfo extends StatelessWidget { const _AgencyInfo({required this.agency}); final Agency agency; @override Widget build(BuildContext context) { return Text('Agency: ${agency.name}'); } }
launch_card.dart
textimport 'package:flutter/material.dart'; import '../../models/launch.dart'; import '../../utils/launch_status_colors.dart'; /// Карточка запуска для ленты. /// /// Требования: /// - Мини-изображение миссии (Image.network или placeholder) /// - Название запуска /// - Дата в читаемом формате /// - Агентство (agencyId или название если передано) /// - Статус-бейдж через [LaunchStatusColors] /// - При нажатии — вызов [onTap] /// /// Ключ для тестов — НЕ удалять: [LaunchCard.cardKey] class LaunchCard extends StatelessWidget { /// Ключ для нахождения карточки в тестах. static const cardKey = Key('launch_card'); final Launch launch; final VoidCallback? onTap; const LaunchCard({ super.key, required this.launch, this.onTap, }); String get _formattedDate { final day = launch.date.day.toString().padLeft(2, '0'); final month = launch.date.month.toString().padLeft(2, '0'); final year = launch.date.year; return '$day.$month.$year'; } String get _statusLabel { switch (launch.status) { case LaunchStatus.success: return 'Success'; case LaunchStatus.failure: return 'Failure'; case LaunchStatus.upcoming: return 'Upcoming'; case LaunchStatus.inFlight: return 'In Flight'; case LaunchStatus.partial: return 'Partial'; } } @override Widget build(BuildContext context) { return Card( key: cardKey, child: InkWell( onTap: onTap, child: Padding( padding: const EdgeInsets.all(16), child: Row( children: [ _MissionImage(imageUrl: launch.imageUrl), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( launch.name, style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 4), Text(_formattedDate), const SizedBox(height: 4), Text(launch.agencyId), const SizedBox(height: 8), Chip( label: Text(_statusLabel), backgroundColor: LaunchStatusColors.forStatus(launch.status), ), ], ), ), ], ), ), ), ); } } class _MissionImage extends StatelessWidget { const _MissionImage({this.imageUrl}); final String? imageUrl; @override Widget build(BuildContext context) { if (imageUrl == null || imageUrl!.isEmpty) { return const SizedBox( width: 80, height: 80, child: Center(child: Icon(Icons.rocket_launch)), ); } return ClipRRect( borderRadius: BorderRadius.circular(8), child: Image.network( imageUrl!, width: 80, height: 80, fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) => const SizedBox( width: 80, height: 80, child: Center(child: Icon(Icons.rocket_launch)), ), ), ); } }
launch_list_screen.dart
textimport 'dart:async'; import 'package:flutter/material.dart'; import '../../data/launch_repository.dart'; import '../../models/launch.dart'; import '../launch_detail/launch_detail_screen.dart'; import 'launch_card.dart'; /// Главный экран — лента запусков. /// /// Требования: /// - [CustomScrollView] с [SliverAppBar] + [SliverList] /// - [SliverAppBar]: в развёрнутом виде — изображение, название ближайшего /// upcoming-запуска и обратный отсчёт; в свёрнутом — просто название /// - Бесконечная прокрутка: при достижении конца списка — загрузка /// следующей страницы ([pageSize] элементов) /// - [RefreshIndicator]: pull-to-refresh сбрасывает страницу и перезагружает /// - Пустое состояние: placeholder «Запусков не найдено» /// - Индикатор загрузки внизу списка /// /// Ключи для тестов — НЕ удалять: /// [listKey] — основной прокручиваемый виджет /// [emptyStateKey] — placeholder при пустом списке /// [loadingIndicatorKey] — CircularProgressIndicator в конце списка /// [sliverAppBarNameKey] — Text с именем ближайшего запуска в SliverAppBar /// [sliverAppBarCountdownKey] — Text с обратным отсчётом в SliverAppBar class LaunchListScreen extends StatefulWidget { static const listKey = Key('launch_list'); static const emptyStateKey = Key('launch_list_empty_state'); static const loadingIndicatorKey = Key('launch_list_loading_indicator'); static const sliverAppBarNameKey = Key('sliver_app_bar_launch_name'); static const sliverAppBarCountdownKey = Key('sliver_app_bar_countdown'); final LaunchRepository repository; final int pageSize; const LaunchListScreen({ super.key, required this.repository, this.pageSize = 10, }); @override State<LaunchListScreen> createState() => _LaunchListScreenState(); } class _LaunchListScreenState extends State<LaunchListScreen> { final _launches = <Launch>[]; final _scrollController = ScrollController(); bool _isLoading = false; bool _hasMore = true; int _page = 0; // Ближайший upcoming-запуск для SliverAppBar Launch? _nextLaunch; // Таймер обратного отсчёта в SliverAppBar Timer? _countdownTimer; Duration _remaining = Duration.zero; void _findNextLaunch() { final upcoming = _launches .where((l) => l.status == LaunchStatus.upcoming) .toList() ..sort((a, b) => a.date.compareTo(b.date)); _nextLaunch = upcoming.isNotEmpty ? upcoming.first : null; } @override void initState() { super.initState(); _loadPage(); } @override void dispose() { _scrollController.dispose(); _countdownTimer?.cancel(); super.dispose(); } Future<void> _loadPage() async { if (_isLoading || !_hasMore) return; setState(() => _isLoading = true); final items = await widget.repository .getLaunches(page: _page, pageSize: widget.pageSize); if (items.isEmpty) { _hasMore = false; } else { _launches.addAll(items); _page++; _findNextLaunch(); } setState(() => _isLoading = false); if (_nextLaunch != null) _startCountdown(); } Future<void> _onRefresh() async { _page = 0; _hasMore = true; _launches.clear(); _nextLaunch = null; _countdownTimer?.cancel(); await _loadPage(); } void _startCountdown() { _countdownTimer?.cancel(); if (_nextLaunch == null) return; _countdownTimer = Timer.periodic(const Duration(seconds: 1), (_) { final now = DateTime.now(); final diff = _nextLaunch!.date.difference(now); if (diff.isNegative) { setState(() => _remaining = Duration.zero); _countdownTimer?.cancel(); } else { setState(() => _remaining = diff); } }); } String _formatCountdown(Duration d) { if (d.isNegative) return '00:00:00:00'; final days = d.inDays.toString().padLeft(2, '0'); final hours = d.inHours.remainder(24).toString().padLeft(2, '0'); final minutes = d.inMinutes.remainder(60).toString().padLeft(2, '0'); final seconds = d.inSeconds.remainder(60).toString().padLeft(2, '0'); return '$days:$hours:$minutes:$seconds'; } @override Widget build(BuildContext context) { final slivers = <Widget>[ SliverAppBar( expandedHeight: 280, pinned: true, flexibleSpace: FlexibleSpaceBar( title: Text( _nextLaunch?.name ?? 'AstroLaunch', key: LaunchListScreen.sliverAppBarNameKey, ), background: Stack( fit: StackFit.expand, children: [ if (_nextLaunch?.imageUrl != null) Image.network( _nextLaunch!.imageUrl!, fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) => Container(color: Colors.grey), ) else Container(color: Colors.grey), Positioned( bottom: 80, left: 16, child: Text( _formatCountdown(_remaining), key: LaunchListScreen.sliverAppBarCountdownKey, style: const TextStyle( color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold, ), ), ), ], ), ), ), ]; if (_launches.isEmpty && !_isLoading) { slivers.add( SliverFillRemaining( child: Center( key: LaunchListScreen.emptyStateKey, child: const EmptyLaunchState(), ), ), ); } else { slivers.add( SliverList( delegate: SliverChildBuilderDelegate( (context, index) { if (index < _launches.length) { final launch = _launches[index]; return LaunchCard( launch: launch, onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) => LaunchDetailScreen(launch: launch), ), ); }, ); } else { // Loading indicator return const Padding( padding: EdgeInsets.all(16), child: Center( child: CircularProgressIndicator( key: LaunchListScreen.loadingIndicatorKey), ), ); } }, childCount: _launches.length + (_isLoading ? 1 : 0), ), ), ); } return Scaffold( body: RefreshIndicator( onRefresh: _onRefresh, child: NotificationListener<ScrollNotification>( onNotification: (notification) { if (notification is ScrollEndNotification) { if (notification.metrics.atEdge && notification.metrics.pixels > 0 && !_isLoading && _hasMore) { _loadPage(); } } return false; }, child: CustomScrollView( key: LaunchListScreen.listKey, controller: _scrollController, slivers: slivers, ), ), ), ); } } /// Placeholder при отсутствии данных. /// Должен содержать иконку ракеты и текст "Запусков не найдено". class EmptyLaunchState extends StatelessWidget { const EmptyLaunchState({super.key}); @override Widget build(BuildContext context) { // TODO: Реализуйте return const Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.rocket_launch_outlined, size: 80), SizedBox(height: 16), Text('Запусков не найдено'), ], ); } }
А вот main.dart:
textimport 'package:flutter/material.dart'; // Модели и репозиторий — скопировать из HW01/HW02: // git checkout HW02 -- lib/models/ lib/data/mock_data.dart lib/data/mock_launch_repository.dart import 'data/mock_launch_repository.dart'; import 'presentation/launch_list/launch_list_screen.dart'; void main() => runApp(const AstroLaunchApp()); class AstroLaunchApp extends StatelessWidget { const AstroLaunchApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'AstroLaunch', theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), useMaterial3: true, ), home: LaunchListScreen(repository: MockLaunchRepository()), ); } }
Да — у тебя уже почти всё собрано. Ниже дам, что именно надо поправить, чтобы пройти эти тесты, и готовые версии файлов.
Главные проблемы в текущем коде:
MockLaunchRepository не реализует LaunchRepository.mock_data.dart даты сейчас завязаны на DateTime.now(), а в README прямо сказано держать даты в диапазоне 2026, иначе тест на upcoming может ломаться.LaunchListScreen таймер отсчёта запускается, но _remaining не выставляется сразу до первого тика — тест обычно это переживёт, но лучше исправить.NotificationListener, это ок, но надёжнее привязать загрузку к ScrollController, как требует условие.LaunchCard и LaunchListScreen у тебя уже в целом правильные.Ниже рабочие версии.
lib/data/mock_launch_repository.dartdartimport 'dart:async'; import 'launch_repository.dart'; import 'mock_data.dart'; import '../models/launch.dart'; class MockLaunchRepository implements LaunchRepository { Future<List<Launch>> getLaunches({int page = 0, int pageSize = 10}) async { await Future.delayed(const Duration(milliseconds: 200)); final start = page * pageSize; if (start >= mockLaunches.length) return []; final end = (start + pageSize).clamp(0, mockLaunches.length); return mockLaunches.sublist(start, end); } Future<Launch?> getLaunchById(String id) async { await Future.delayed(const Duration(milliseconds: 200)); try { return mockLaunches.firstWhere((e) => e.id == id); } catch (_) { return null; } } }
lib/data/mock_data.dartСамое важное: убираем случайные даты вокруг DateTime.now() и делаем даты в 2026.
dartimport '../models/launch.dart'; import '../models/rocket.dart'; import '../models/agency.dart'; import '../models/astronaut.dart'; final mockRockets = [ const Rocket(id: 'r1', name: 'Falcon 9'), const Rocket(id: 'r2', name: 'Soyuz'), const Rocket(id: 'r3', name: 'Starship'), ]; final mockAgencies = [ const Agency(id: 'a1', name: 'NASA', type: AgencyType.government), const Agency(id: 'a2', name: 'SpaceX', type: AgencyType.commercial), const Agency(id: 'a3', name: 'ESA', type: AgencyType.government), const Agency(id: 'a4', name: 'Roscosmos', type: AgencyType.government), ]; final mockAstronauts = [ const Astronaut(id: 'as1', name: 'A1'), const Astronaut(id: 'as2', name: 'A2'), const Astronaut(id: 'as3', name: 'A3'), const Astronaut(id: 'as4', name: 'A4'), const Astronaut(id: 'as5', name: 'A5'), ]; final mockLaunches = [ Launch( id: 'l1', name: 'Starlink 12', date: DateTime.utc(2026, 1, 10), status: LaunchStatus.success, rocketId: 'r1', agencyId: 'SpaceX', description: 'Deployment mission', ), Launch( id: 'l2', name: 'Soyuz MS-28', date: DateTime.utc(2026, 5, 2), status: LaunchStatus.upcoming, rocketId: 'r2', agencyId: 'Roscosmos', description: 'Crewed mission', ), Launch( id: 'l3', name: 'Artemis Cargo', date: DateTime.utc(2026, 2, 18), status: LaunchStatus.failure, rocketId: 'r1', agencyId: 'NASA', ), Launch( id: 'l4', name: 'Europa Probe', date: DateTime.utc(2026, 6, 14), status: LaunchStatus.upcoming, rocketId: 'r3', agencyId: 'ESA', ), Launch( id: 'l5', name: 'Falcon Heavy Demo', date: DateTime.utc(2026, 3, 7), status: LaunchStatus.success, rocketId: 'r1', agencyId: 'SpaceX', ), Launch( id: 'l6', name: 'Lunar Supply', date: DateTime.utc(2026, 7, 21), status: LaunchStatus.upcoming, rocketId: 'r3', agencyId: 'NASA', ), Launch( id: 'l7', name: 'Cargo Resupply', date: DateTime.utc(2026, 4, 9), status: LaunchStatus.partial, rocketId: 'r1', agencyId: 'SpaceX', ), Launch( id: 'l8', name: 'Deep Space Test', date: DateTime.utc(2026, 8, 3), status: LaunchStatus.upcoming, rocketId: 'r3', agencyId: 'ESA', ), Launch( id: 'l9', name: 'Orbital Mission 9', date: DateTime.utc(2026, 1, 28), status: LaunchStatus.success, rocketId: 'r2', agencyId: 'Roscosmos', ), Launch( id: 'l10', name: 'Mars Relay', date: DateTime.utc(2026, 9, 11), status: LaunchStatus.upcoming, rocketId: 'r3', agencyId: 'NASA', ), ];
lib/presentation/launch_list/launch_list_screen.dartВот версия, которая лучше совпадает с ТЗ и тестами.
dartimport 'dart:async'; import 'package:flutter/material.dart'; import '../../data/launch_repository.dart'; import '../../models/launch.dart'; import '../launch_detail/launch_detail_screen.dart'; import 'launch_card.dart'; class LaunchListScreen extends StatefulWidget { static const listKey = Key('launch_list'); static const emptyStateKey = Key('launch_list_empty_state'); static const loadingIndicatorKey = Key('launch_list_loading_indicator'); static const sliverAppBarNameKey = Key('sliver_app_bar_launch_name'); static const sliverAppBarCountdownKey = Key('sliver_app_bar_countdown'); final LaunchRepository repository; final int pageSize; const LaunchListScreen({ super.key, required this.repository, this.pageSize = 10, }); State<LaunchListScreen> createState() => _LaunchListScreenState(); } class _LaunchListScreenState extends State<LaunchListScreen> { final List<Launch> _launches = []; final ScrollController _scrollController = ScrollController(); bool _isLoading = false; bool _hasMore = true; int _page = 0; Launch? _nextLaunch; Timer? _countdownTimer; Duration _remaining = Duration.zero; void initState() { super.initState(); _scrollController.addListener(_onScroll); _loadPage(); } void dispose() { _scrollController.removeListener(_onScroll); _scrollController.dispose(); _countdownTimer?.cancel(); super.dispose(); } void _onScroll() { if (!_scrollController.hasClients || _isLoading || !_hasMore) return; final position = _scrollController.position; if (position.pixels >= position.maxScrollExtent - 200) { _loadPage(); } } Future<void> _loadPage() async { if (_isLoading || !_hasMore) return; setState(() { _isLoading = true; }); final items = await widget.repository.getLaunches( page: _page, pageSize: widget.pageSize, ); if (!mounted) return; setState(() { if (items.isEmpty) { _hasMore = false; } else { _launches.addAll(items); _page++; _updateNextLaunch(); } _isLoading = false; }); _startCountdownIfNeeded(); } Future<void> _onRefresh() async { _countdownTimer?.cancel(); setState(() { _launches.clear(); _page = 0; _hasMore = true; _isLoading = false; _nextLaunch = null; _remaining = Duration.zero; }); await _loadPage(); } void _updateNextLaunch() { final upcoming = _launches .where((launch) => launch.status == LaunchStatus.upcoming) .toList() ..sort((a, b) => a.date.compareTo(b.date)); _nextLaunch = upcoming.isEmpty ? null : upcoming.first; } void _startCountdownIfNeeded() { _countdownTimer?.cancel(); if (_nextLaunch == null) { setState(() { _remaining = Duration.zero; }); return; } void updateRemaining() { final diff = _nextLaunch!.date.difference(DateTime.now()); if (!mounted) return; setState(() { _remaining = diff.isNegative ? Duration.zero : diff; }); if (diff.isNegative) { _countdownTimer?.cancel(); } } updateRemaining(); _countdownTimer = Timer.periodic(const Duration(seconds: 1), (_) { updateRemaining(); }); } String _formatCountdown(Duration duration) { final days = duration.inDays.toString().padLeft(2, '0'); final hours = duration.inHours.remainder(24).toString().padLeft(2, '0'); final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); return '$days:$hours:$minutes:$seconds'; } Widget build(BuildContext context) { return Scaffold( body: RefreshIndicator( onRefresh: _onRefresh, child: CustomScrollView( key: LaunchListScreen.listKey, controller: _scrollController, slivers: [ SliverAppBar( pinned: true, expandedHeight: 220, flexibleSpace: FlexibleSpaceBar( title: Text( _nextLaunch?.name ?? 'AstroLaunch', key: LaunchListScreen.sliverAppBarNameKey, ), background: Container( padding: const EdgeInsets.fromLTRB(16, 100, 16, 24), decoration: const BoxDecoration( gradient: LinearGradient( colors: [Colors.indigo, Colors.black87], begin: Alignment.topCenter, end: Alignment.bottomCenter, ), ), child: Align( alignment: Alignment.bottomLeft, child: Text( _formatCountdown(_remaining), key: LaunchListScreen.sliverAppBarCountdownKey, style: const TextStyle( color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold, ), ), ), ), ), ), if (_launches.isEmpty && !_isLoading) SliverFillRemaining( child: Center( key: LaunchListScreen.emptyStateKey, child: const EmptyLaunchState(), ), ) else SliverList( delegate: SliverChildBuilderDelegate( (context, index) { if (index < _launches.length) { final launch = _launches[index]; return LaunchCard( launch: launch, onTap: () { Navigator.push( context, MaterialPageRoute( builder: (_) => LaunchDetailScreen(launch: launch), ), ); }, ); } return const Padding( padding: EdgeInsets.all(16), child: Center( child: CircularProgressIndicator( key: LaunchListScreen.loadingIndicatorKey, ), ), ); }, childCount: _launches.length + (_isLoading ? 1 : 0), ), ), ], ), ), ); } } class EmptyLaunchState extends StatelessWidget { const EmptyLaunchState({super.key}); Widget build(BuildContext context) { return const Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.rocket_launch_outlined, size: 80), SizedBox(height: 16), Text('Запусков не найдено'), ], ); } }
lib/presentation/launch_list/launch_card.dartУ тебя тут уже почти всё хорошо. Я бы оставил вот так:
dartimport 'package:flutter/material.dart'; import '../../models/launch.dart'; import '../../utils/launch_status_colors.dart'; class LaunchCard extends StatelessWidget { static const cardKey = Key('launch_card'); final Launch launch; final VoidCallback? onTap; const LaunchCard({ super.key, required this.launch, this.onTap, }); String get _formattedDate { final day = launch.date.day.toString().padLeft(2, '0'); final month = launch.date.month.toString().padLeft(2, '0'); final year = launch.date.year; return '$day.$month.$year'; } String get _statusLabel { switch (launch.status) { case LaunchStatus.success: return 'Success'; case LaunchStatus.failure: return 'Failure'; case LaunchStatus.upcoming: return 'Upcoming'; case LaunchStatus.inFlight: return 'In Flight'; case LaunchStatus.partial: return 'Partial'; } } Widget build(BuildContext context) { return Card( key: cardKey, margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.all(16), child: Row( children: [ _MissionImage(imageUrl: launch.imageUrl), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( launch.name, style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 4), Text(launch.agencyId), const SizedBox(height: 4), Text(_formattedDate), const SizedBox(height: 8), Chip( label: Text(_statusLabel), backgroundColor: LaunchStatusColors.forStatus(launch.status), ), ], ), ), ], ), ), ), ); } } class _MissionImage extends StatelessWidget { const _MissionImage({this.imageUrl}); final String? imageUrl; Widget build(BuildContext context) { if (imageUrl == null || imageUrl!.isEmpty) { return const SizedBox( width: 80, height: 80, child: Center(child: Icon(Icons.rocket_launch)), ); } return ClipRRect( borderRadius: BorderRadius.circular(8), child: Image.network( imageUrl!, width: 80, height: 80, fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) => const SizedBox( width: 80, height: 80, child: Center(child: Icon(Icons.rocket_launch)), ), ), ); } }
Текущий LaunchCard уже проходил бы:
Текущий LaunchListScreen у тебя тоже был близок, но я бы всё же поменял на версию выше, чтобы:
ScrollControllerLaunchCard рендерится и содержит name, agencyId, date.Text с
LaunchListScreen.sliverAppBarNameKeyLaunchListScreen.sliverAppBarCountdownKeyRefreshIndicator.CircularProgressIndicator с нужным key._hasMore = false, индикатор больше не показывается.emptyStateKey и текст Запусков не найдено.Navigator.push.В main.dart у тебя всё ок, если MockLaunchRepository уже реализует LaunchRepository.
После замены файлов прогоняй:
bashflutter pub get dart run build_runner build --delete-conflicting-outputs flutter test flutter analyze dart format .
Если хочешь, я могу следующим сообщением собрать тебе полный набор файлов целиком, чтобы ты просто вставил их без ручного сравнения.
и объясни что где ты добавил и зачем
Можно оставить случайность, но в твоём текущем варианте лучше не оставлять как есть.
Почему:
У тебя сейчас дата строится так:
dartfinal now = DateTime.now(); final date = isUpcoming ? now.add(Duration(days: random.nextInt(30))) : now.subtract(Duration(days: random.nextInt(30)));
Проблема тут не в Random, а в том, что даты считаются от текущего момента, а не фиксированно в 2026.
Что из этого следует:
То есть:
DateTime.now() — лучше нельзя.Пример безопасного варианта со случайностью:
dartimport 'dart:math'; import '../models/launch.dart'; import '../models/rocket.dart'; import '../models/agency.dart'; import '../models/astronaut.dart'; final mockRockets = [ const Rocket(id: 'r1', name: 'Falcon 9'), const Rocket(id: 'r2', name: 'Soyuz'), const Rocket(id: 'r3', name: 'Starship'), ]; final mockAgencies = [ const Agency(id: 'a1', name: 'NASA', type: AgencyType.government), const Agency(id: 'a2', name: 'SpaceX', type: AgencyType.commercial), const Agency(id: 'a3', name: 'ESA', type: AgencyType.government), const Agency(id: 'a4', name: 'Roscosmos', type: AgencyType.government), ]; final mockAstronauts = [ const Astronaut(id: 'as1', name: 'A1'), const Astronaut(id: 'as2', name: 'A2'), const Astronaut(id: 'as3', name: 'A3'), const Astronaut(id: 'as4', name: 'A4'), const Astronaut(id: 'as5', name: 'A5'), ]; final random = Random(42); // фиксируем seed для стабильности final mockLaunches = List.generate(10, (i) { final isUpcoming = i % 3 == 0; final month = random.nextInt(12) + 1; final day = random.nextInt(28) + 1; return Launch( id: 'l${i + 1}', name: 'Launch ${i + 1}', date: DateTime.utc(2026, month, day), status: isUpcoming ? LaunchStatus.upcoming : LaunchStatus.success, rocketId: mockRockets[random.nextInt(mockRockets.length)].id, agencyId: mockAgencies[random.nextInt(mockAgencies.length)].name, description: random.nextBool() ? 'Mission ${i + 1}' : null, imageUrl: null, ); });
Так будет и случайно, и стабильно.
Теперь что я добавил и зачем.
MockLaunchRepository implements LaunchRepositoryБыло:
dartclass MockLaunchRepository {
Стало:
dartclass MockLaunchRepository implements LaunchRepository {
Зачем:
в условии сказано, что репозиторий должен реализовывать интерфейс.
И LaunchListScreen принимает именно LaunchRepository. Это делает код правильным по архитектуре и соответствует ТЗ.
getLaunches({int page, int pageSize})Было:
dartFuture<List<Launch>> getLaunches() async
Стало:
dartFuture<List<Launch>> getLaunches({int page = 0, int pageSize = 10}) async
И внутри:
dartfinal start = page * pageSize; if (start >= mockLaunches.length) return []; final end = (start + pageSize).clamp(0, mockLaunches.length); return mockLaunches.sublist(start, end);
Зачем: это и есть пагинация.
Пример:
page=0, pageSize=5 → элементы 0..4page=1, pageSize=5 → элементы 5..9Без этого не пройдут тесты на первую страницу и дозагрузку.
LaunchListScreen я повесил listener на ScrollControllerДобавил в initState:
dart_scrollController.addListener(_onScroll);
и в dispose:
dart_scrollController.removeListener(_onScroll);
и сам метод:
dartvoid _onScroll() { if (!_scrollController.hasClients || _isLoading || !_hasMore) return; final position = _scrollController.position; if (position.pixels >= position.maxScrollExtent - 200) { _loadPage(); } }
Зачем:
по условию пагинация должна идти через ScrollController.
У тебя до этого было через NotificationListener<ScrollNotification>, это тоже рабочий способ, но я привёл к тому, что прямо просят.
_remainingДобавил внутри _startCountdownIfNeeded() вызов функции обновления до Timer.periodic:
dartupdateRemaining(); _countdownTimer = Timer.periodic(...)
Зачем:
иначе _remaining сначала равен Duration.zero, и только через секунду обновляется.
Так SliverAppBar сразу показывает нормальный отсчёт.
Добавил в _onRefresh():
dart_launches.clear(); _page = 0; _hasMore = true; _isLoading = false; _nextLaunch = null; _remaining = Duration.zero;
Зачем: pull-to-refresh должен вернуть экран в исходное состояние и заново загрузить первую страницу.
_updateNextLaunch()Я вынес поиск ближайшего upcoming в отдельный метод:
dartvoid _updateNextLaunch() { final upcoming = _launches .where((launch) => launch.status == LaunchStatus.upcoming) .toList() ..sort((a, b) => a.date.compareTo(b.date)); _nextLaunch = upcoming.isEmpty ? null : upcoming.first; }
Зачем:
SliverAppBar должен показывать ближайший upcoming-запуск, а не просто любой upcoming.
Оставил:
dartCenter( key: LaunchListScreen.emptyStateKey, child: const EmptyLaunchState(), )
Зачем: тест ищет именно этот key.
Оставил внизу списка:
dartCircularProgressIndicator( key: LaunchListScreen.loadingIndicatorKey, )
Зачем: тест проверяет появление именно этого индикатора при подгрузке следующей страницы.
Оставил:
dartNavigator.push( context, MaterialPageRoute( builder: (_) => LaunchDetailScreen(launch: launch), ), );
Зачем:
тест смотрит сам факт Navigator.push, а не содержимое второго экрана.
LaunchCard почти ничего критично не менялТам у тебя уже было всё нужное:
onTap.Это уже соответствует тестам.
Итог по mock_data.dart:
DateTime.now(), если хочешь стабильный результат;Если хочешь, я могу теперь показать тебе минимальный diff: что именно поменять в твоих текущих файлах буквально по строчкам, без переписывания целиком.