stuff
Some checks are pending
Web App CI / test (push) Waiting to run
Web App CI / build (push) Blocked by required conditions
Deploy Admin Panel / Deploy Admin Panel (push) Waiting to run
Deploy Admin Panel / Admin Panel Verification (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions

This commit is contained in:
Dmitry 2025-12-19 02:17:03 +03:00
parent 910986fadc
commit a50fb4110a
21 changed files with 1201 additions and 537 deletions

View file

@ -7,3 +7,5 @@ Modularize and split into files,
When you finish with another step:
Write unit tests for all the functionalities
Update PROGESS.md and TODO.md
Never run the tests, until specificly asked to
Never build the apps, until specificly asked to

View file

@ -84,9 +84,11 @@
- Web playback: download bytes via authenticated API and play from memory (fixes 401 on `<audio src>` without headers)
- Added unit tests for voice metadata fetching and voice-bytes download
- Card UI: moved voice play icon into a `Stack` overlay so it doesn't take an extra row on the card (with widget test coverage)
- CardViewer UI: navigation arrows are now constrained under the card on wide screens; favorites (heart) button is positioned closer to the card
- Added widget test coverage for wide-screen navigation button positioning
- CardViewer UI: navigation arrows are constrained under the card on wide screens; favorites (heart) is now part of the card (top-right); voice controls moved to top-left (header text stays centered)
- Added widget test coverage for wide-screen navigation + header control positioning
- Pack details: "Перемешать" button no longer has an active (pressed) state (widget test updated)
- Pack details: Control buttons (Сетка/Карточки, Перемешать, Избранные) now hide text when there's not enough space, showing only icons (widget tests added)
- Pack details: removed the "view" (eye) icon on cards; moved the favorite (heart) icon to the top-right corner (list + grid) (widget test updated)
- Added app version display on authentication page
- **UI Components Refactoring**: Extracted authentication components
- Created `SignInWithGoogleButton` component for Google authentication
@ -126,6 +128,15 @@
- Backend generator picks cards from pack pool and stores `matrixSize` in `uiData`
- Web UI: image grid + target `original` word; wrong = shake + error sound; correct = flip to translation then disappear
- Admin: question type selector + matrix size form; JSON (de)serialization + vitest coverage
- **CardViewer Carousel Looping**: Implemented infinite carousel scrolling for card viewer
- Cards now loop seamlessly: last card → first card and first card → last card
- Works for both swipe gestures and navigation buttons
- Added comprehensive unit tests for looping functionality
- **Loading Screens Enhancement**: Replaced basic loading screens with themed images
- Removed CircularProgressIndicator as requested
- Added sun image (el_sol.png) for light theme and moon image (la_luna.png) for dark theme
- Updated both initialization placeholder and app loading screens to use theme-aware images
- Added assets/images/ to pubspec.yaml for image loading
### Mobile Application (mnemo_cards)
- **Framework**: Flutter
@ -228,4 +239,4 @@
---
*Last updated: December 18, 2025*
*Last updated: December 19, 2025*

17
TODO.md
View file

@ -54,7 +54,9 @@
- ✅ Added unit test for version display on auth page
- ✅ Admin: fixed `BulkCardEditor` pack loading TypeScript error (TS2352) + unit test; added JSDOM polyfills for Radix Select
- ✅ Web: CardViewer navigation controls are constrained under the card on wide screens (widget test added)
- ✅ Web: CardViewer header controls — voice left + favorite right (heart is part of the card), while the title stays centered (widget test added)
- ✅ Web: Pack details "Перемешать" button has no active (pressed) state (widget test updated)
- ✅ Web: Pack details cards — removed "view" (eye) icon; moved favorite (heart) to top-right corner (widget test updated)
- ✅ **User Telegram Field Tests**: Added comprehensive unit tests for telegram field
- Created `test/models/user_model_telegram_test.dart` with 6 test cases
- Created `test/user_dto_telegram_test.dart` with 7 test cases
@ -75,7 +77,16 @@
- Removed undefined `cachedImage` variable usage
- Cleaned up unused imports
- Fixed all linter errors and compilation issues
- ✅ **Pack Details Controls**: Control buttons (Сетка/Карточки, Перемешать, Избранные) now adaptively hide text when there's insufficient space
- Implemented LayoutBuilder + TextPainter to measure text width
- Buttons maintain full width expansion while showing only icons when text doesn't fit
- Added comprehensive widget tests for both wide and narrow layouts
- ✅ Backend: Added regression test ensuring generated tests are linked to packs
- ✅ **Loading Screens Enhancement**: Replaced basic loading screens with themed images
- Removed CircularProgressIndicator from both initialization and app loading screens
- Added sun image (el_sol.png) for light theme and moon image (la_luna.png) for dark theme
- Created theme-aware loading screen components using StateBuilder
- Added assets/images/ to pubspec.yaml and updated app.dart with new loading UI
- [ ] **Integration Tests**: Implement comprehensive integration testing
- API endpoint testing
@ -139,6 +150,10 @@
- Auto-generate matrix images from pack card pool (generator framework)
- Web: multi-stage single question (shake on wrong, flip+reveal translation on correct, then disappear)
- Admin: matrix size configuration + JSON (de)serialization + tests
- [x] **CardViewer Carousel Looping**: Implement infinite carousel scrolling for card viewer
- Cards loop seamlessly: last card → first card and first card → last card
- Works for both swipe gestures and navigation buttons
- Added comprehensive unit tests for looping functionality
- [ ] **User Experience**: Enhanced user experience features
- Offline mode support
- Progressive Web App (PWA)
@ -198,4 +213,4 @@
---
*Last updated: December 18, 2025*
*Last updated: December 19, 2025*

View file

@ -0,0 +1,95 @@
import { describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BulkCardEditor } from '@/components/BulkCardEditor'
vi.mock('@/api/packs', () => {
return {
packsApi: {
getPacks: vi.fn(),
},
isPacksApiError: () => false,
}
})
import { packsApi } from '@/api/packs'
function renderWithQuery(ui: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return render(
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
)
}
describe('BulkCardEditor', () => {
it('loads packs and shows their titles in the pack select', async () => {
vi.mocked(packsApi.getPacks).mockResolvedValue({
items: [
{
id: 'pack-1',
title: 'Pack One',
cards: 10,
enabled: true,
order: 0,
},
{
id: 'pack-empty-title',
title: ' ',
cards: 0,
enabled: true,
order: 1,
},
],
total: 2,
page: 1,
limit: 100,
totalPages: 1,
})
const images = [
{
id: 'img-1',
file: new File(['x'], 'img-1.png', { type: 'image/png' }),
preview: 'data:image/png;base64,AA==',
base64: 'data:image/png;base64,AA==',
},
]
renderWithQuery(
<BulkCardEditor
images={images}
onComplete={vi.fn()}
onCancel={vi.fn()}
/>,
)
await waitFor(() => {
expect(packsApi.getPacks).toHaveBeenCalledWith({
page: 1,
limit: 100,
search: '',
})
})
const placeholder = screen.getByText('Select a pack (optional)')
const trigger = placeholder.closest('button')
expect(trigger).toBeTruthy()
trigger!.focus()
fireEvent.keyDown(trigger!, { key: 'ArrowDown' })
await waitFor(() => {
expect(screen.getByText('None')).toBeInTheDocument()
expect(screen.getByText('Pack One')).toBeInTheDocument()
expect(screen.getByText('pack-empty-title')).toBeInTheDocument()
})
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 640 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

View file

@ -9,6 +9,7 @@ import 'di/app_scope/app_scope_container.dart';
import 'di/app_scope/app_scope_holder.dart';
import 'domain/state/theme_state_manager.dart';
import 'main.dart' show scaffoldMessengerKey;
import 'presentation/theme/app_colors.dart';
import 'presentation/theme/app_theme.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
@ -31,7 +32,7 @@ class App extends StatelessWidget {
builder: (context, appScope) {
return _AppInitializer(appScope: appScope);
},
placeholder: const _LoadingScreen(message: 'Инициализация...'),
placeholder: const _LoadingPlaceholder(message: 'Инициализация...'),
),
);
}
@ -170,7 +171,7 @@ class _AppInitializerState extends State<_AppInitializer> {
if (!_isInitialized) {
log('Showing loading screen', name: 'App');
return const _LoadingScreen(message: 'Loading...');
return _LoadingScreen(appScope: widget.appScope, message: 'Loading...');
}
log('Building main app', name: 'App');
@ -207,26 +208,35 @@ class _AppInitializerState extends State<_AppInitializer> {
}
}
/// Экран загрузки
class _LoadingScreen extends StatelessWidget {
const _LoadingScreen({required this.message});
/// Плейсхолдер экрана загрузки (без доступа к теме)
class _LoadingPlaceholder extends StatelessWidget {
const _LoadingPlaceholder({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: AppTheme.light,
home: Scaffold(
backgroundColor: Theme.of(context).colorScheme.brightness == Brightness.dark ? AppTheme.dark.scaffoldBackgroundColor : AppTheme.light.scaffoldBackgroundColor,
backgroundColor: AppTheme.light.scaffoldBackgroundColor,
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
Image.asset(
'assets/images/el_sol.png',
width: 120,
height: 120,
),
const SizedBox(height: 24),
Text(
message,
style: const TextStyle(fontSize: 18),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.black,
),
),
],
),
@ -236,3 +246,70 @@ class _LoadingScreen extends StatelessWidget {
}
}
/// Экран загрузки
class _LoadingScreen extends StatelessWidget {
const _LoadingScreen({
required this.appScope,
required this.message,
});
final AppScopeContainer appScope;
final String message;
@override
Widget build(BuildContext context) {
return FutureBuilder<ThemeState>(
future: _getThemeState(),
builder: (context, snapshot) {
final themeState = snapshot.data ?? ThemeState(ThemeMode.system);
final isDark = themeState.mode == ThemeMode.dark ||
(themeState.mode == ThemeMode.system &&
MediaQuery.platformBrightnessOf(context) == Brightness.dark);
return MaterialApp(
theme: AppTheme.light,
darkTheme: AppTheme.dark,
themeMode: themeState.mode,
home: Scaffold(
backgroundColor: isDark
? AppTheme.dark.scaffoldBackgroundColor
: AppTheme.light.scaffoldBackgroundColor,
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
isDark
? 'assets/images/la_luna.png'
: 'assets/images/el_sol.png',
width: 120,
height: 120,
),
const SizedBox(height: 24),
Text(
message,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: isDark ? AppColors.white : AppColors.black,
),
),
],
),
),
),
);
},
);
}
Future<ThemeState> _getThemeState() async {
try {
return appScope.themeManager.state;
} catch (e) {
// Fallback to system theme if themeManager is not available
return ThemeState(ThemeMode.system);
}
}
}

View file

@ -1,5 +1,7 @@
import 'dart:async';
import 'dart:developer';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:go_router/go_router.dart';
@ -24,11 +26,13 @@ class GamePage extends StatefulWidget {
const GamePage({
required this.testId,
this.returnToLocation,
this.questionAudioPlayback,
super.key,
});
final String testId;
final String? returnToLocation;
final QuestionAudioPlayback? questionAudioPlayback;
static const questionCardKey = Key('game_question_card');
@override
@ -38,6 +42,7 @@ class GamePage extends StatefulWidget {
class _GamePageState extends State<GamePage> {
GameSoundService? _soundService;
double _maxContentWidth = 880;
AudioPlayer? _questionAudioPlayer;
@override
void initState() {
@ -49,6 +54,7 @@ class _GamePageState extends State<GamePage> {
@override
void dispose() {
_soundService?.dispose();
_questionAudioPlayer?.dispose();
super.dispose();
}
@ -112,7 +118,7 @@ class _GamePageState extends State<GamePage> {
title: const Text('Game Test'),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => _showExitConfirmation(context),
onPressed: () => unawaited(_leaveGame()),
),
actions: [
if (state.maybeWhen(
@ -249,14 +255,6 @@ class _GamePageState extends State<GamePage> {
timeElapsed: sessionElapsed,
),
),
SizedBox(width: isNarrow ? 12.w : 20.w),
Tooltip(
message: 'Exit game',
child: IconButton(
onPressed: () => _showExitConfirmation(context),
icon: const Icon(Icons.close),
),
),
],
),
),
@ -291,7 +289,10 @@ class _GamePageState extends State<GamePage> {
padding: EdgeInsets.all(isNarrow ? 14.w : 18.w),
child: currentQuestion is GameQuestionMatrix
? MatrixWidget(question: currentQuestion.question)
: QuestionDisplay(question: currentQuestion),
: QuestionDisplay(
question: currentQuestion,
onPlayAudio: widget.questionAudioPlayback ?? _playQuestionAudio,
),
),
),
),
@ -367,6 +368,12 @@ class _GamePageState extends State<GamePage> {
);
}
Future<void> _playQuestionAudio(Uri audioUri) async {
final player = _questionAudioPlayer ??= AudioPlayer();
await player.stop();
await player.play(UrlSource(audioUri.toString()));
}
Widget _buildCompletedView(TestDto test, GameSessionResult result) {
final accuracy = result.totalQuestions > 0
? (result.correctAnswers / result.totalQuestions * 100).round()
@ -549,7 +556,7 @@ class _GamePageState extends State<GamePage> {
final userScope = appScope?.userScopeHolder.scope;
try {
// Exit means "discard progress" (as per confirmation dialog text).
// Exit discards current progress.
// Fire-and-forget: navigation should not wait for state cleanup.
userScope?.testsModule.testsStateManager
.resetGameSession()
@ -567,13 +574,19 @@ class _GamePageState extends State<GamePage> {
if (!mounted) return;
final router = GoRouter.of(context);
final router = GoRouter.maybeOf(context);
if (router == null) {
// Allows widget tests (or non-go_router embeds) to tap the close button
// without crashing. In the real app we always use go_router.
unawaited(Navigator.of(context).maybePop());
return;
}
final before = router.routeInformationProvider.value.uri.toString();
// Prefer popping when we have a real back stack (normal flow: TestPage -> GamePage via push).
// Note: on web, go_router's `canPop()` may be true even when popping is a no-op; so we
// verify by comparing location after pop and fall back to go().
// verify by comparing location after pop and fall back to replace().
if (router.canPop()) {
router.pop();
await Future<void>.delayed(Duration.zero);
@ -583,8 +596,8 @@ class _GamePageState extends State<GamePage> {
if (after != before) return;
}
// Deep-link (or no-op pop) fallback.
router.go(widget.returnToLocation ?? '/home');
// Deep-link (or no-op pop) fallback: use replace() to avoid adding to history.
router.replace<void>(widget.returnToLocation ?? '/home');
}
void _restartGame() {
@ -633,30 +646,4 @@ class _GamePageState extends State<GamePage> {
orElse: () => false,
);
}
void _showExitConfirmation(BuildContext context) {
showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Exit Game'),
content: const Text('Are you sure you want to exit? Your progress will be lost.'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
Navigator.of(dialogContext).pop();
// Let the dialog route actually get removed from the Navigator
// before we decide whether we can pop the game route.
await Future<void>.delayed(Duration.zero);
await _leaveGame();
},
child: const Text('Exit'),
),
],
),
);
}
}

View file

@ -15,6 +15,7 @@ import '../../../presentation/widgets/pack_details_header.dart';
import '../../../presentation/widgets/pack_details_controls.dart';
import '../../../presentation/widgets/pack_details_sidebar.dart';
import '../../../presentation/widgets/mnemo_text.dart';
import '../../../presentation/widgets/card_favorite_button.dart';
import '../../../presentation/widgets/card_viewer.dart';
import '../../../presentation/widgets/shuffle_animated_switcher.dart';
import '../../../presentation/widgets/shuffle_movement_wrapper.dart';
@ -691,15 +692,33 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
Color packColor,
) {
final card = cards[index];
return LayoutBuilder(
builder: (context, constraints) {
const favoriteHitSize = 56.0;
return GestureDetector(
onTap: () => _openCardViewer(cards, index, packColor),
behavior: HitTestBehavior.opaque,
onTapUp: (details) {
final local = details.localPosition;
final isInFavoriteArea =
local.dx > constraints.maxWidth - favoriteHitSize &&
local.dy < favoriteHitSize;
if (!isInFavoriteArea) {
_openCardViewer(cards, index, packColor);
}
},
child: Container(
decoration: BoxDecoration(
color: packColor.withValues(alpha: 0.05),
border: Border.all(color: packColor.withValues(alpha: 0.3), width: 1),
border: Border.all(
color: packColor.withValues(alpha: 0.3),
width: 1,
),
borderRadius: BorderRadius.circular(8),
),
child: Row(
child: Stack(
children: [
Row(
children: [
// Изображение слева
Container(
@ -716,19 +735,22 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
),
),
// Контентц справа
// Контент справа (с резервом под сердце сверху справа)
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
padding: const EdgeInsets.fromLTRB(8, 8, 56, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Original текст
if (card.original != null && card.original!.isNotEmpty)
if (card.original != null &&
card.original!.isNotEmpty)
MnemoText(
card.original,
textStyle: Theme.of(context).textTheme.titleMedium
textStyle: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
fontSize: 16,
fontWeight: FontWeight.w600,
@ -744,13 +766,18 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
card.translation!.isNotEmpty)
MnemoText(
card.translation,
textStyle: Theme.of(context).textTheme.bodyMedium
textStyle: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
fontSize: 14,
fontWeight: FontWeight.w400,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.7),
)
.colorScheme
.onSurface
.withValues(alpha: 0.7),
),
maxLines: 1,
textAlign: TextAlign.left,
@ -762,7 +789,9 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
if (card.mnemo != null && card.mnemo!.isNotEmpty)
MnemoText(
card.mnemo,
textStyle: Theme.of(context).textTheme.bodySmall
textStyle: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
fontSize: 12,
fontWeight: FontWeight.w700,
@ -775,75 +804,21 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
),
),
),
// Иконки действий справа
Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Иконка избранного
GestureDetector(
onTap: () async => await _toggleCardFavorite(card.id),
child: Builder(
builder: (context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(4),
),
child: Icon(
_isCardFavorite(card.id)
? Icons.favorite
: Icons.favorite_border,
size: 16,
color: _isCardFavorite(card.id)
? Colors.red
: colorScheme.onSurface.withOpacity(0.6),
),
);
},
),
],
),
const SizedBox(height: 8),
// Иконка просмотра
GestureDetector(
onTap: () {
// TODO: Открыть полноэкранный просмотр карточки
log(
'Card view tapped: ${card.id}',
name: 'PackDetailsPage',
);
},
child: Builder(
builder: (context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(4),
),
child: Icon(
Icons.visibility_outlined,
size: 16,
color: colorScheme.onSurface.withOpacity(0.6),
),
);
},
),
),
],
),
// Сердце в правом верхнем углу (как в CardViewer)
Positioned(
top: 4,
right: 4,
child: CardFavoriteButton(cardId: card.id, size: 16),
),
],
),
),
);
},
);
}
/// Изображение карточки (вынесено в отдельный метод для переиспользования)

View file

@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:yx_scope_flutter/yx_scope_flutter.dart';
import 'package:yx_state_flutter/yx_state_flutter.dart';
import '../../di/user_scope/user_scope.dart';
/// Heart button for marking a card as favorite.
///
/// Reads [UserScope] via `yx_scope` and listens to favorites updates via
/// `yx_state`.
class CardFavoriteButton extends StatelessWidget {
const CardFavoriteButton({
required this.cardId,
this.size = 24,
super.key,
});
final String cardId;
final double size;
UserScope? _tryGetUserScope(BuildContext context) {
try {
return ScopeProvider.of<UserScope>(context, listen: true);
} catch (_) {
return null;
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final userScope = _tryGetUserScope(context);
if (userScope == null) {
return const SizedBox.shrink();
}
return StateBuilder(
stateReadable: userScope.favoritesStateManager,
builder: (context, _, __) {
final isFavorite =
userScope.favoritesStateManager.isFavorite(cardId);
return IconButton(
tooltip: isFavorite ? 'Убрать из избранного' : 'В избранное',
onPressed: () => userScope.favoritesStateManager.toggleFavorite(
cardId,
),
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.surface.withOpacity(0.9),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: colorScheme.shadow.withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Icon(
isFavorite ? Icons.favorite : Icons.favorite_border,
color: isFavorite ? Colors.red : colorScheme.onSurface,
size: size,
),
),
);
},
);
}
}

View file

@ -3,11 +3,10 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:yx_scope_flutter/yx_scope_flutter.dart';
import '../../di/user_scope/user_scope.dart';
import '../../domain/config/api_config_v2.dart';
import '../../presentation/theme/app_colors.dart';
import 'card_favorite_button.dart';
import 'card_voice_controls.dart';
import 'mnemo_text.dart';
@ -45,8 +44,9 @@ class _CardViewerState extends State<CardViewer> {
@override
void initState() {
super.initState();
_currentIndex = widget.initialIndex;
_pageController = PageController(initialPage: widget.initialIndex);
// Начинаем с середины для поддержки бесконечной прокрутки
_pageController = PageController(initialPage: widget.initialIndex + 1);
_currentIndex = widget.initialIndex + 1; // Синхронизируем с page controller
// Устанавливаем фокус для обработки клавиатуры
WidgetsBinding.instance.addPostFrameCallback((_) {
@ -69,15 +69,21 @@ class _CardViewerState extends State<CardViewer> {
});
}
/// Получает правильный индекс карточки для бесконечной прокрутки
int _getCardIndex(int pageIndex) {
if (widget.cards.isEmpty) return 0;
// Убираем смещение (pageIndex - 1) и применяем модуль для зацикливания
return (pageIndex - 1) % widget.cards.length;
}
/// Получает текущий индекс карточки
int get _currentCardIndex => _getCardIndex(_currentIndex);
void _goToPrevious() {
if (widget.cards.isEmpty) return;
final newIndex = _currentIndex == 0
? widget.cards.length - 1 // Циклический переход: с первой на последнюю
: _currentIndex - 1;
_pageController.animateToPage(
newIndex,
_pageController.previousPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
@ -86,12 +92,7 @@ class _CardViewerState extends State<CardViewer> {
void _goToNext() {
if (widget.cards.isEmpty) return;
final newIndex = _currentIndex == widget.cards.length - 1
? 0 // Циклический переход: с последней на первую
: _currentIndex + 1;
_pageController.animateToPage(
newIndex,
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
@ -147,28 +148,40 @@ class _CardViewerState extends State<CardViewer> {
math.max(24.0, cardWidth * 0.15),
);
final isWideLayoutForFloatingControls = cardSideInset >= 120;
final double favoriteTop = isWideLayoutForFloatingControls
? math.max(16.0, cardTop + 12.0)
: 16.0;
final double favoriteRight = isWideLayoutForFloatingControls
? math.max(16.0, cardSideInset - 16.0)
: 80.0;
return Stack(
children: [
// PageView с карточками
PageView.builder(
controller: _pageController,
itemCount: widget.cards.length,
itemCount: widget.cards.length + 2, // +2 для бесконечной прокрутки
onPageChanged: (index) {
// Обработка переходов между дополнительными страницами для бесконечной прокрутки
if (index == 0) {
// Переход к последней карточке
Future.delayed(const Duration(milliseconds: 50), () {
_pageController.jumpToPage(widget.cards.length);
});
setState(() {
_currentIndex = widget.cards.length;
});
} else if (index == widget.cards.length + 1) {
// Переход к первой карточке
Future.delayed(const Duration(milliseconds: 50), () {
_pageController.jumpToPage(1);
});
setState(() {
_currentIndex = 1;
});
} else {
setState(() {
_currentIndex = index;
});
}
// Обновляем фокус для обработки клавиатуры
_focusNode.requestFocus();
},
itemBuilder: (context, index) {
final cardIndex = _getCardIndex(index);
return AnimatedBuilder(
animation: _pageController,
builder: (context, child) {
@ -182,8 +195,8 @@ class _CardViewerState extends State<CardViewer> {
child: Transform.scale(
scale: value,
child: _buildCard(
widget.cards[index],
index,
widget.cards[cardIndex],
cardIndex,
packColor,
),
),
@ -213,66 +226,6 @@ class _CardViewerState extends State<CardViewer> {
),
),
// Кнопка избранного (ближе к карточке на больших экранах)
Positioned(
top: favoriteTop,
right: favoriteRight,
child: Builder(
builder: (context) {
UserScope? userScope;
try {
userScope = ScopeProvider.of<UserScope>(
context,
listen: true,
);
} catch (_) {
userScope = null;
}
if (userScope == null || widget.cards.isEmpty) {
return const SizedBox.shrink();
}
final scope = userScope;
final currentCard = widget.cards[_currentIndex];
final isFavorite = scope.favoritesStateManager
.isFavorite(currentCard.id);
return IconButton(
onPressed: () async {
await scope.favoritesStateManager
.toggleFavorite(currentCard.id);
if (mounted) {
setState(() {});
}
},
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.surface.withOpacity(0.9),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: colorScheme.shadow.withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Icon(
isFavorite
? Icons.favorite
: Icons.favorite_border,
color: isFavorite
? Colors.red
: colorScheme.onSurface,
size: 24,
),
),
);
},
),
),
// Индикатор текущей карточки
Positioned(
top: 16,
@ -287,7 +240,7 @@ class _CardViewerState extends State<CardViewer> {
borderRadius: BorderRadius.circular(20),
),
child: Text(
'${_currentIndex + 1} / ${widget.cards.length}',
'${_currentCardIndex + 1} / ${widget.cards.length}',
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 16,
@ -443,7 +396,9 @@ class _CardSide extends StatelessWidget {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Container(
return Hero(
tag: 'card_${card.id}',
child: Container(
width: cardWidth,
height: cardHeight,
decoration: BoxDecoration(
@ -467,6 +422,7 @@ class _CardSide extends StatelessWidget {
? _buildFrontLayout()
: _buildBackLayout(),
),
),
);
}
@ -484,9 +440,10 @@ class _CardSide extends StatelessWidget {
child: Stack(
children: [
Padding(
// Reserve corner space for the overlay button without
// adding an extra row in the layout.
padding: const EdgeInsets.only(right: 56),
// Reserve symmetric corner space for overlay controls
// (voice on the left, favorite on the right) while keeping
// the text centered.
padding: const EdgeInsets.symmetric(horizontal: 56),
child: Column(
children: [
// Original текст - нормальный цвет для хорошей читаемости
@ -522,13 +479,18 @@ class _CardSide extends StatelessWidget {
),
Positioned(
top: 0,
right: 0,
left: 0,
child: CardVoiceControls(
packId: packId,
cardId: card.id,
accentColor: packColor,
),
),
Positioned(
top: 0,
right: 0,
child: CardFavoriteButton(cardId: card.id),
),
],
),
),

View file

@ -3,14 +3,18 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../../../domain/models/game_question.dart';
typedef QuestionAudioPlayback = Future<void> Function(Uri audioUri);
/// Widget for displaying game questions (text, image, audio)
class QuestionDisplay extends StatelessWidget {
const QuestionDisplay({
required this.question,
this.onPlayAudio,
super.key,
});
final GameQuestion question;
final QuestionAudioPlayback? onPlayAudio;
@override
Widget build(BuildContext context) {
@ -115,20 +119,116 @@ class QuestionDisplay extends StatelessWidget {
// Audio button
if (audio != null) ...[
SizedBox(height: 12.h),
IconButton(
onPressed: () {
// TODO: Implement audio playback
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Audio playback not implemented yet')),
_QuestionAudioButton(
audioUrl: audio,
onPlayAudio: onPlayAudio,
),
],
],
);
},
}
}
class _QuestionAudioButton extends StatefulWidget {
const _QuestionAudioButton({
required this.audioUrl,
required this.onPlayAudio,
});
final String audioUrl;
final QuestionAudioPlayback? onPlayAudio;
@override
State<_QuestionAudioButton> createState() => _QuestionAudioButtonState();
}
class _QuestionAudioButtonState extends State<_QuestionAudioButton> {
bool _isPlaying = false;
String? _playError;
bool get _hasPlayer => widget.onPlayAudio != null;
bool _isValidHttpUrl(Uri uri) {
return uri.hasScheme && (uri.scheme == 'http' || uri.scheme == 'https');
}
Future<void> _onPressed() async {
setState(() {
_playError = null;
});
final uri = Uri.tryParse(widget.audioUrl);
if (uri == null || !_isValidHttpUrl(uri)) {
setState(() {
_playError = 'Некорректная ссылка на аудио';
});
return;
}
if (!_hasPlayer) {
setState(() {
_playError = 'Проигрывание аудио недоступно';
});
return;
}
setState(() {
_isPlaying = true;
});
try {
await widget.onPlayAudio!(uri);
} catch (_) {
if (!mounted) return;
setState(() {
_playError = 'Не удалось воспроизвести аудио';
});
} finally {
if (!mounted) return;
setState(() {
_isPlaying = false;
});
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: _isPlaying ? null : _onPressed,
icon: Icon(
Icons.volume_up,
size: 32.sp,
color: Theme.of(context).colorScheme.primary,
color: colorScheme.primary,
),
tooltip: 'Воспроизвести',
),
if (_playError != null)
Padding(
padding: EdgeInsets.only(top: 6.h),
child: SelectableText.rich(
TextSpan(
children: [
const WidgetSpan(
child: Icon(
Icons.error_outline,
color: Colors.red,
size: 16,
),
),
const TextSpan(text: ' '),
TextSpan(
text: _playError!,
style: const TextStyle(color: Colors.red),
),
],
),
textAlign: TextAlign.center,
),
),
],
);
}

View file

@ -13,7 +13,7 @@ import 'mnemo_text.dart';
/// - Показывает original текст (сверху)
/// - Показывает translation текст (снизу, серым)
/// - Показывает mnemo текст (совсем внизу)
/// - Иконки действий: сердце (избранное) и глаз (просмотр)
/// - Иконка избранного (сердце) в правом верхнем углу
class PackCardItem extends StatelessWidget {
const PackCardItem({
required this.card,
@ -26,21 +26,79 @@ import 'mnemo_text.dart';
super.key,
});
static const favoriteRootKeyPrefix = 'pack_card_item_favorite_';
static const cardRootKeyPrefix = 'pack_card_item_';
final GameCardDto card;
final Color color;
final String packId;
final VoidCallback? onTap;
final bool isFavorite;
final VoidCallback? onToggleFavorite;
/// Kept for backward compatibility. The UI no longer shows a dedicated "view"
/// icon; opening is handled by [onTap].
final VoidCallback? onView;
bool _isInFavoriteArea(Offset localPosition, double maxWidth) {
const favoriteHitSize = 44.0;
return localPosition.dx > maxWidth - favoriteHitSize &&
localPosition.dy < favoriteHitSize;
}
Widget _buildFavoriteButton(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isEnabled = onToggleFavorite != null;
return Semantics(
button: true,
enabled: isEnabled,
label: isFavorite ? 'Убрать из избранного' : 'В избранное',
child: GestureDetector(
onTap: onToggleFavorite,
child: Container(
key: ValueKey('$favoriteRootKeyPrefix${card.id}'),
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: colorScheme.surface.withOpacity(0.9),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: colorScheme.shadow.withOpacity(0.2),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: Icon(
isFavorite ? Icons.favorite : Icons.favorite_border,
size: 14,
color: isFavorite ? Colors.red : colorScheme.onSurface,
),
),
),
);
}
@override
Widget build(BuildContext context) {
final hasImage = card.image != null && card.image!.isNotEmpty;
return LayoutBuilder(
builder: (context, constraints) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
onTapUp: (details) {
final local = details.localPosition;
if (_isInFavoriteArea(local, constraints.maxWidth)) {
return;
}
onTap?.call();
},
child: Hero(
tag: 'card_${card.id}',
child: Container(
key: ValueKey('$cardRootKeyPrefix${card.id}'),
alignment: Alignment.center,
decoration: BoxDecoration(
color: Theme.of(context).scaffoldBackgroundColor,
@ -51,19 +109,29 @@ import 'mnemo_text.dart';
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Theme.of(context)
.colorScheme
.primary
.withOpacity(0.05),
color: Theme.of(context).colorScheme.primary.withOpacity(0.05),
blurRadius: 2.0,
),
],
),
clipBehavior: Clip.antiAlias,
child: hasImage
child: Stack(
fit: StackFit.expand,
children: [
hasImage
? _buildCardWithImage(context)
: _buildCardTextOnly(context),
Positioned(
top: 4,
right: 4,
child: _buildFavoriteButton(context),
),
],
),
),
),
);
},
);
}
@ -74,22 +142,18 @@ import 'mnemo_text.dart';
final cardWidth = constraints.maxWidth;
final cardHeight = constraints.maxHeight;
return Stack(
return Column(
children: [
Column(
children: [
// Original и Translation сверху
// Original и Translation сверху (с резервом под сердце справа)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4.0,
vertical: 4.0,
),
padding: const EdgeInsets.fromLTRB(4, 4, 28, 4),
child: Column(
children: [
if (card.original != null && card.original!.isNotEmpty)
MnemoText(
card.original,
textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
textStyle: Theme.of(context).textTheme.bodyMedium
?.copyWith(
fontSize: max(10, cardWidth * 0.12),
fontWeight: FontWeight.w600,
),
@ -126,7 +190,7 @@ import 'mnemo_text.dart';
// Mnemo снизу
if (card.mnemo != null && card.mnemo!.isNotEmpty)
Padding(
padding: const EdgeInsets.all(4.0),
padding: const EdgeInsets.fromLTRB(4, 4, 28, 4),
child: MnemoText(
card.mnemo,
textStyle: Theme.of(context).textTheme.bodySmall?.copyWith(
@ -138,61 +202,6 @@ import 'mnemo_text.dart';
),
),
],
),
// Иконки действий в правом нижнем углу
Positioned(
bottom: 4,
right: 4,
child: Builder(
builder: (context) {
final colorScheme = Theme.of(context).colorScheme;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
// Иконка избранного
GestureDetector(
onTap: onToggleFavorite,
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: colorScheme.surface.withOpacity(0.9),
borderRadius: BorderRadius.circular(4),
),
child: Icon(
isFavorite
? Icons.favorite
: Icons.favorite_border,
size: 12,
color: isFavorite
? Colors.red
: colorScheme.onSurface.withOpacity(0.6),
),
),
),
const SizedBox(width: 4),
// Иконка просмотра
GestureDetector(
onTap: onView ?? onTap,
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: colorScheme.surface.withOpacity(0.9),
borderRadius: BorderRadius.circular(4),
),
child: Icon(
Icons.visibility_outlined,
size: 12,
color: colorScheme.onSurface.withOpacity(0.6),
),
),
),
],
);
},
),
),
],
);
},
);
@ -201,7 +210,7 @@ import 'mnemo_text.dart';
/// Карточка только с текстом (без изображения)
Widget _buildCardTextOnly(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
padding: const EdgeInsets.fromLTRB(8, 8, 28, 8),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [

View file

@ -114,19 +114,44 @@ class _ControlButton extends StatelessWidget {
border: Border.all(color: borderColor, width: 1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
iconWidget,
const SizedBox(width: 8),
Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(
child: LayoutBuilder(
builder: (context, constraints) {
final textStyle = theme.textTheme.bodyMedium?.copyWith(
color: textColor,
fontWeight: isActive ? FontWeight.w600 : FontWeight.w400,
);
// Измеряем ширину текста
final textPainter = TextPainter(
text: TextSpan(text: label, style: textStyle),
maxLines: 1,
textDirection: TextDirection.ltr,
)..layout();
// Проверяем, помещается ли текст (иконка 20 + отступ 8 + текст)
const iconAndSpacingWidth = 20.0 + 8.0;
final availableWidth = constraints.maxWidth;
final requiredWidth = iconAndSpacingWidth + textPainter.width;
final showText = requiredWidth <= availableWidth;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
iconWidget,
if (showText) ...[
const SizedBox(width: 8),
Flexible(
child: Text(
label,
style: textStyle,
overflow: TextOverflow.ellipsis,
),
),
],
],
);
},
),
),
),

View file

@ -51,12 +51,16 @@ class PackDetailsSidebar extends StatelessWidget {
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
Expanded(
child: Text(
'Проверка знаний',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 16),

View file

@ -109,6 +109,7 @@ flutter:
assets:
- icons/
- assets/images/
fonts:
- family: Nunito

View file

@ -144,19 +144,6 @@ void main() {
);
}
Future<void> pumpUntil(
WidgetTester tester,
Finder finder, {
int maxPumps = 60,
Duration step = const Duration(milliseconds: 50),
}) async {
for (var i = 0; i < maxPumps; i++) {
await tester.pump(step);
if (finder.evaluate().isNotEmpty) return;
}
throw TestFailure('Timed out waiting for: $finder');
}
group('GamePage', () {
testWidgets('should display preparing state', (tester) async {
await testsStateManager.setStateForTest(
@ -206,6 +193,50 @@ void main() {
expect(testsStateManager.resumeCalls, equals(1));
});
testWidgets('passes question audio url to playback callback', (tester) async {
Uri? playedUri;
final questions = [
GameQuestion.multipleChoice(
MultipleChoiceQuestion(
id: 'q1',
question: 'Listen and choose',
audio: 'https://example.com/sound.mp3',
options: const ['A', 'B'],
correctAnswer: 'A',
word: 'sound',
),
),
];
await testsStateManager.setStateForTest(
TestsState.gameSessionActive(
test: TestDto(id: 'test1', name: 'Test Game', questions: const []),
questions: questions,
currentQuestionIndex: 0,
currentResult: null,
questionResults: const {},
isAnswerSubmitted: false,
isCorrect: false,
),
);
await pumpGamePage(
tester,
child: GamePage(
testId: 'test1',
questionAudioPlayback: (uri) async {
playedUri = uri;
},
),
);
await tester.pump();
await tester.tap(find.byIcon(Icons.volume_up));
await tester.pump();
expect(playedUri, Uri.parse('https://example.com/sound.mp3'));
});
testWidgets('should display active game state', (tester) async {
final questions = [
GameQuestion.multipleChoice(
@ -290,7 +321,7 @@ void main() {
expect(material.surfaceTintColor, theme.colorScheme.surfaceTint);
});
testWidgets('shows exit icon beside progress', (tester) async {
testWidgets('shows close icon (app bar) and no exit icon near progress', (tester) async {
final questions = [
GameQuestion.multipleChoice(
MultipleChoiceQuestion(
@ -319,6 +350,7 @@ void main() {
await tester.pump();
expect(find.byIcon(Icons.close), findsOneWidget);
expect(find.byTooltip('Exit game'), findsNothing);
});
testWidgets('starts session when not active', (tester) async {
@ -356,7 +388,7 @@ void main() {
expect(find.text('Back'), findsOneWidget);
});
testWidgets('should show exit confirmation dialog', (tester) async {
testWidgets('does not show exit confirmation dialog', (tester) async {
final questions = [
GameQuestion.multipleChoice(
MultipleChoiceQuestion(
@ -385,12 +417,12 @@ void main() {
await tester.pump();
await tester.tap(find.byIcon(Icons.close));
await pumpUntil(tester, find.text('Exit Game'));
await tester.pump();
expect(find.text('Exit Game'), findsOneWidget);
expect(find.text('Exit Game'), findsNothing);
expect(
find.text('Are you sure you want to exit? Your progress will be lost.'),
findsOneWidget,
findsNothing,
);
});

View file

@ -6,6 +6,7 @@ import 'package:mnemo_cards_web_v2/di/app_scope/app_scope_container.dart';
import 'package:mnemo_cards_web_v2/di/user_scope/user_scope_holder.dart';
import 'package:mnemo_cards_web_v2/domain/services/http_repository_v2.dart';
import 'package:mnemo_cards_web_v2/presentation/pages/pack_details/pack_details_page.dart';
import 'package:mnemo_cards_web_v2/presentation/widgets/pack_card_item.dart';
import 'package:yx_scope/yx_scope.dart';
import 'package:yx_scope_flutter/yx_scope_flutter.dart';
@ -105,5 +106,79 @@ void main() {
expect(find.text('Test Pack'), findsWidgets);
});
testWidgets('does not show "view" (eye) icon on cards', (tester) async {
const packId = 'test-pack-3';
final packDto = CardPackDto(
id: packId,
title: 'Test Pack',
subtitle: 'Test Subtitle',
color: null,
version: 'test',
cards: const [
GameCardDto(
id: 'card-1',
original: 'test',
translation: 'тест',
mnemo: null,
transcription: null,
),
],
);
when(() => mockHttpRepository.getPack(packId))
.thenAnswer((_) async => packDto);
await pumpPackDetails(tester, packId: packId);
await tester.pumpAndSettle();
expect(find.byIcon(Icons.visibility_outlined), findsNothing);
});
testWidgets('renders favorite icon in top-right corner of card',
(tester) async {
const packId = 'test-pack-4';
final packDto = CardPackDto(
id: packId,
title: 'Test Pack',
subtitle: 'Test Subtitle',
color: null,
version: 'test',
cards: const [
GameCardDto(
id: 'card-1',
original: 'test',
translation: 'тест',
mnemo: null,
transcription: null,
),
],
);
when(() => mockHttpRepository.getPack(packId))
.thenAnswer((_) async => packDto);
await pumpPackDetails(tester, packId: packId);
await tester.pumpAndSettle();
final cardFinder = find.byKey(
const ValueKey('${PackCardItem.cardRootKeyPrefix}card-1'),
);
final favoriteFinder = find.byKey(
const ValueKey('${PackCardItem.favoriteRootKeyPrefix}card-1'),
);
expect(cardFinder, findsOneWidget);
expect(favoriteFinder, findsOneWidget);
final cardRect = tester.getRect(cardFinder);
final favoriteRect = tester.getRect(favoriteFinder);
// Should be near the top-right corner of the card.
expect(favoriteRect.top - cardRect.top, lessThan(16));
expect(cardRect.right - favoriteRect.right, lessThan(16));
});
});
}

View file

@ -1,8 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:mnemo_cards_web_v2/di/user_scope/user_scope.dart';
import 'package:mnemo_cards_web_v2/domain/state/favorites_state_manager.dart';
import 'package:mnemo_cards_web_v2/presentation/widgets/card_voice_controls.dart';
import 'package:mnemo_cards_web_v2/presentation/widgets/card_viewer.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:yx_scope/yx_scope.dart';
import 'package:yx_scope_flutter/yx_scope_flutter.dart';
class _MockUserScope extends Mock implements UserScope {}
void main() {
const cards = [
@ -32,15 +40,23 @@ void main() {
Future<void> _pumpViewer(
WidgetTester tester, {
required int initialIndex,
ScopeStateHolder<UserScope?>? userScopeHolder,
}) async {
await tester.pumpWidget(
MaterialApp(
final app = MaterialApp(
home: CardViewer(
cards: cards,
packId: 'pack-1',
initialIndex: initialIndex,
packColor: Colors.blue,
),
);
await tester.pumpWidget(
userScopeHolder == null
? app
: ScopeProvider<UserScope>(
holder: userScopeHolder,
child: app,
),
);
await tester.pumpAndSettle();
@ -129,4 +145,112 @@ void main() {
findsOneWidget,
);
});
testWidgets(
'CardViewer header: voice is left, favorite is right, text stays centered',
(tester) async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final favoritesStateManager = FavoritesStateManager(
sharedPreferences: prefs,
);
await favoritesStateManager.loadFavorites();
final userScope = _MockUserScope();
when(() => userScope.favoritesStateManager)
.thenReturn(favoritesStateManager);
final userScopeHolder = ScopeStateHolder<UserScope?>(
ScopeState.available(scope: userScope),
);
await _pumpViewer(
tester,
initialIndex: 0,
userScopeHolder: userScopeHolder,
);
final cardFinder = find.byKey(const ValueKey('front')).first;
expect(cardFinder, findsOneWidget);
// Voice controls are positioned on the left.
final voicePositionedFinder = find.ancestor(
of: find.byType(CardVoiceControls),
matching: find.byType(Positioned),
);
final voicePositioned = tester.widget<Positioned>(voicePositionedFinder);
expect(voicePositioned.left, equals(0));
expect(voicePositioned.right, isNull);
// Favorite button is positioned on the right (border icon by default).
final favoriteIconFinder = find.byIcon(Icons.favorite_border);
expect(favoriteIconFinder, findsOneWidget);
final favoritePositionedFinder = find.ancestor(
of: favoriteIconFinder,
matching: find.byType(Positioned),
);
final favoritePositioned =
tester.widget<Positioned>(favoritePositionedFinder);
expect(favoritePositioned.right, equals(0));
expect(favoritePositioned.left, isNull);
// The title text stays centered within the card.
final cardRect = tester.getRect(cardFinder);
final originalCenter = tester.getCenter(find.text('Hola'));
expect((originalCenter.dx - cardRect.center.dx).abs(), lessThan(4));
},
);
testWidgets('CardViewer loops to first card when swiping past last card', (tester) async {
await _pumpViewer(tester, initialIndex: 2); // Начинаем с последней карточки
expect(find.text('3 / 3'), findsOneWidget);
expect(find.text('Gracias'), findsOneWidget);
// Свайпаем вправо (к следующей карточке, которая должна быть первой)
await tester.fling(find.byType(PageView), const Offset(-400, 0), 1000);
await tester.pumpAndSettle();
// Должны увидеть первую карточку
expect(find.text('1 / 3'), findsOneWidget);
expect(find.text('Hola'), findsOneWidget);
});
testWidgets('CardViewer loops to last card when swiping before first card', (tester) async {
await _pumpViewer(tester, initialIndex: 0); // Начинаем с первой карточки
expect(find.text('1 / 3'), findsOneWidget);
expect(find.text('Hola'), findsOneWidget);
// Свайпаем влево (к предыдущей карточке, которая должна быть последней)
await tester.fling(find.byType(PageView), const Offset(400, 0), 1000);
await tester.pumpAndSettle();
// Должны увидеть последнюю карточку
expect(find.text('3 / 3'), findsOneWidget);
expect(find.text('Gracias'), findsOneWidget);
});
testWidgets('CardViewer navigation buttons loop correctly', (tester) async {
await _pumpViewer(tester, initialIndex: 2); // Начинаем с последней карточки
expect(find.text('3 / 3'), findsOneWidget);
expect(find.text('Gracias'), findsOneWidget);
// Нажимаем кнопку "далее" - должны перейти к первой карточке
final forwardButtonFinder = find.widgetWithIcon(IconButton, Icons.arrow_forward);
await tester.tap(forwardButtonFinder);
await tester.pumpAndSettle();
expect(find.text('1 / 3'), findsOneWidget);
expect(find.text('Hola'), findsOneWidget);
// Нажимаем кнопку "назад" - должны перейти к последней карточке
final backButtonFinder = find.widgetWithIcon(IconButton, Icons.arrow_back);
await tester.tap(backButtonFinder);
await tester.pumpAndSettle();
expect(find.text('3 / 3'), findsOneWidget);
expect(find.text('Gracias'), findsOneWidget);
});
}

View file

@ -139,7 +139,7 @@ void main() {
MultipleChoiceQuestion(
id: 'q1',
question: 'Listen and choose',
audio: 'sound.mp3',
audio: 'https://example.com/sound.mp3',
options: ['A', 'B'],
correctAnswer: 'A',
word: 'sound',
@ -151,6 +151,36 @@ void main() {
expect(find.byIcon(Icons.volume_up), findsOneWidget);
});
testWidgets('should call audio playback with passed url', (tester) async {
Uri? playedUri;
final question = GameQuestion.multipleChoice(
MultipleChoiceQuestion(
id: 'q1',
question: 'Listen and choose',
audio: 'https://example.com/sound.mp3',
options: ['A', 'B'],
correctAnswer: 'A',
word: 'sound',
),
);
await tester.pumpWidget(
wrap(
QuestionDisplay(
question: question,
onPlayAudio: (uri) async {
playedUri = uri;
},
),
),
);
await tester.tap(find.byIcon(Icons.volume_up));
await tester.pump();
expect(playedUri, Uri.parse('https://example.com/sound.mp3'));
});
testWidgets('should handle image loading error gracefully', (tester) async {
final question = GameQuestion.multipleChoice(
MultipleChoiceQuestion(

View file

@ -65,8 +65,12 @@ void main() {
),
);
// Находим кнопку перемешивания по иконке shuffle
final shuffleIcon = find.byIcon(Icons.shuffle);
expect(shuffleIcon, findsOneWidget);
final shuffleGesture = find.ancestor(
of: find.text('Перемешать'),
of: shuffleIcon,
matching: find.byType(GestureDetector),
);
expect(shuffleGesture, findsOneWidget);
@ -85,6 +89,69 @@ void main() {
expect(border.top.color, AppColors.borderGray);
},
);
testWidgets(
'shows text when there is enough space',
(tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: SizedBox(
width: 800, // Достаточная ширина для показа текста (каждая кнопка получит ~266px)
child: PackDetailsControls(
isGridView: true,
onToggleView: _noop,
onShuffle: _noop,
onToggleFavorites: _noop,
shuffleTurns: 0.0,
),
),
),
),
);
await tester.pumpAndSettle();
// Проверяем что текст отображается
expect(find.text('Сетка'), findsOneWidget);
expect(find.text('Перемешать'), findsOneWidget);
expect(find.text('Избранные'), findsOneWidget);
},
);
testWidgets(
'hides text when there is not enough space',
(tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: SizedBox(
width: 150, // Недостаточная ширина для показа текста
child: PackDetailsControls(
isGridView: true,
onToggleView: _noop,
onShuffle: _noop,
onToggleFavorites: _noop,
shuffleTurns: 0.0,
),
),
),
),
);
await tester.pumpAndSettle();
// Проверяем что текст скрыт, но иконки остались
expect(find.text('Сетка'), findsNothing);
expect(find.text('Перемешать'), findsNothing);
expect(find.text('Избранные'), findsNothing);
// Но иконки должны быть видны
expect(find.byIcon(Icons.grid_view), findsOneWidget);
expect(find.byIcon(Icons.shuffle), findsOneWidget);
expect(find.byIcon(Icons.favorite_border), findsOneWidget);
},
);
}
void _noop() {}