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: When you finish with another step:
Write unit tests for all the functionalities Write unit tests for all the functionalities
Update PROGESS.md and TODO.md 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) - 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 - 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) - 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 - 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 button positioning - 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: "Перемешать" 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 - Added app version display on authentication page
- **UI Components Refactoring**: Extracted authentication components - **UI Components Refactoring**: Extracted authentication components
- Created `SignInWithGoogleButton` component for Google authentication - Created `SignInWithGoogleButton` component for Google authentication
@ -126,6 +128,15 @@
- Backend generator picks cards from pack pool and stores `matrixSize` in `uiData` - 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 - 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 - 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) ### Mobile Application (mnemo_cards)
- **Framework**: Flutter - **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 - ✅ 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 - ✅ 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 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 "Перемешать" 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 - ✅ **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/models/user_model_telegram_test.dart` with 6 test cases
- Created `test/user_dto_telegram_test.dart` with 7 test cases - Created `test/user_dto_telegram_test.dart` with 7 test cases
@ -75,7 +77,16 @@
- Removed undefined `cachedImage` variable usage - Removed undefined `cachedImage` variable usage
- Cleaned up unused imports - Cleaned up unused imports
- Fixed all linter errors and compilation issues - 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 - ✅ 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 - [ ] **Integration Tests**: Implement comprehensive integration testing
- API endpoint testing - API endpoint testing
@ -139,6 +150,10 @@
- Auto-generate matrix images from pack card pool (generator framework) - 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) - Web: multi-stage single question (shake on wrong, flip+reveal translation on correct, then disappear)
- Admin: matrix size configuration + JSON (de)serialization + tests - 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 - [ ] **User Experience**: Enhanced user experience features
- Offline mode support - Offline mode support
- Progressive Web App (PWA) - 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 'di/app_scope/app_scope_holder.dart';
import 'domain/state/theme_state_manager.dart'; import 'domain/state/theme_state_manager.dart';
import 'main.dart' show scaffoldMessengerKey; import 'main.dart' show scaffoldMessengerKey;
import 'presentation/theme/app_colors.dart';
import 'presentation/theme/app_theme.dart'; import 'presentation/theme/app_theme.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
@ -31,7 +32,7 @@ class App extends StatelessWidget {
builder: (context, appScope) { builder: (context, appScope) {
return _AppInitializer(appScope: appScope); return _AppInitializer(appScope: appScope);
}, },
placeholder: const _LoadingScreen(message: 'Инициализация...'), placeholder: const _LoadingPlaceholder(message: 'Инициализация...'),
), ),
); );
} }
@ -170,7 +171,7 @@ class _AppInitializerState extends State<_AppInitializer> {
if (!_isInitialized) { if (!_isInitialized) {
log('Showing loading screen', name: 'App'); log('Showing loading screen', name: 'App');
return const _LoadingScreen(message: 'Loading...'); return _LoadingScreen(appScope: widget.appScope, message: 'Loading...');
} }
log('Building main app', name: 'App'); log('Building main app', name: 'App');
@ -207,26 +208,35 @@ class _AppInitializerState extends State<_AppInitializer> {
} }
} }
/// Экран загрузки /// Плейсхолдер экрана загрузки (без доступа к теме)
class _LoadingScreen extends StatelessWidget { class _LoadingPlaceholder extends StatelessWidget {
const _LoadingScreen({required this.message}); const _LoadingPlaceholder({required this.message});
final String message; final String message;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
theme: AppTheme.light,
home: Scaffold( home: Scaffold(
backgroundColor: Theme.of(context).colorScheme.brightness == Brightness.dark ? AppTheme.dark.scaffoldBackgroundColor : AppTheme.light.scaffoldBackgroundColor, backgroundColor: AppTheme.light.scaffoldBackgroundColor,
body: Center( body: Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const CircularProgressIndicator(), Image.asset(
'assets/images/el_sol.png',
width: 120,
height: 120,
),
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
message, 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 'dart:developer';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@ -24,11 +26,13 @@ class GamePage extends StatefulWidget {
const GamePage({ const GamePage({
required this.testId, required this.testId,
this.returnToLocation, this.returnToLocation,
this.questionAudioPlayback,
super.key, super.key,
}); });
final String testId; final String testId;
final String? returnToLocation; final String? returnToLocation;
final QuestionAudioPlayback? questionAudioPlayback;
static const questionCardKey = Key('game_question_card'); static const questionCardKey = Key('game_question_card');
@override @override
@ -38,6 +42,7 @@ class GamePage extends StatefulWidget {
class _GamePageState extends State<GamePage> { class _GamePageState extends State<GamePage> {
GameSoundService? _soundService; GameSoundService? _soundService;
double _maxContentWidth = 880; double _maxContentWidth = 880;
AudioPlayer? _questionAudioPlayer;
@override @override
void initState() { void initState() {
@ -49,6 +54,7 @@ class _GamePageState extends State<GamePage> {
@override @override
void dispose() { void dispose() {
_soundService?.dispose(); _soundService?.dispose();
_questionAudioPlayer?.dispose();
super.dispose(); super.dispose();
} }
@ -112,7 +118,7 @@ class _GamePageState extends State<GamePage> {
title: const Text('Game Test'), title: const Text('Game Test'),
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
onPressed: () => _showExitConfirmation(context), onPressed: () => unawaited(_leaveGame()),
), ),
actions: [ actions: [
if (state.maybeWhen( if (state.maybeWhen(
@ -249,14 +255,6 @@ class _GamePageState extends State<GamePage> {
timeElapsed: sessionElapsed, 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), padding: EdgeInsets.all(isNarrow ? 14.w : 18.w),
child: currentQuestion is GameQuestionMatrix child: currentQuestion is GameQuestionMatrix
? MatrixWidget(question: currentQuestion.question) ? 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) { Widget _buildCompletedView(TestDto test, GameSessionResult result) {
final accuracy = result.totalQuestions > 0 final accuracy = result.totalQuestions > 0
? (result.correctAnswers / result.totalQuestions * 100).round() ? (result.correctAnswers / result.totalQuestions * 100).round()
@ -549,7 +556,7 @@ class _GamePageState extends State<GamePage> {
final userScope = appScope?.userScopeHolder.scope; final userScope = appScope?.userScopeHolder.scope;
try { try {
// Exit means "discard progress" (as per confirmation dialog text). // Exit discards current progress.
// Fire-and-forget: navigation should not wait for state cleanup. // Fire-and-forget: navigation should not wait for state cleanup.
userScope?.testsModule.testsStateManager userScope?.testsModule.testsStateManager
.resetGameSession() .resetGameSession()
@ -567,13 +574,19 @@ class _GamePageState extends State<GamePage> {
if (!mounted) return; 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(); final before = router.routeInformationProvider.value.uri.toString();
// Prefer popping when we have a real back stack (normal flow: TestPage -> GamePage via push). // 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 // 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()) { if (router.canPop()) {
router.pop(); router.pop();
await Future<void>.delayed(Duration.zero); await Future<void>.delayed(Duration.zero);
@ -583,8 +596,8 @@ class _GamePageState extends State<GamePage> {
if (after != before) return; if (after != before) return;
} }
// Deep-link (or no-op pop) fallback. // Deep-link (or no-op pop) fallback: use replace() to avoid adding to history.
router.go(widget.returnToLocation ?? '/home'); router.replace<void>(widget.returnToLocation ?? '/home');
} }
void _restartGame() { void _restartGame() {
@ -633,30 +646,4 @@ class _GamePageState extends State<GamePage> {
orElse: () => false, 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_controls.dart';
import '../../../presentation/widgets/pack_details_sidebar.dart'; import '../../../presentation/widgets/pack_details_sidebar.dart';
import '../../../presentation/widgets/mnemo_text.dart'; import '../../../presentation/widgets/mnemo_text.dart';
import '../../../presentation/widgets/card_favorite_button.dart';
import '../../../presentation/widgets/card_viewer.dart'; import '../../../presentation/widgets/card_viewer.dart';
import '../../../presentation/widgets/shuffle_animated_switcher.dart'; import '../../../presentation/widgets/shuffle_animated_switcher.dart';
import '../../../presentation/widgets/shuffle_movement_wrapper.dart'; import '../../../presentation/widgets/shuffle_movement_wrapper.dart';
@ -691,158 +692,132 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
Color packColor, Color packColor,
) { ) {
final card = cards[index]; final card = cards[index];
return GestureDetector( return LayoutBuilder(
onTap: () => _openCardViewer(cards, index, packColor), builder: (context, constraints) {
child: Container( const favoriteHitSize = 56.0;
decoration: BoxDecoration(
color: packColor.withValues(alpha: 0.05), return GestureDetector(
border: Border.all(color: packColor.withValues(alpha: 0.3), width: 1), behavior: HitTestBehavior.opaque,
borderRadius: BorderRadius.circular(8), onTapUp: (details) {
), final local = details.localPosition;
child: Row( final isInFavoriteArea =
children: [ local.dx > constraints.maxWidth - favoriteHitSize &&
// Изображение слева local.dy < favoriteHitSize;
Container( if (!isInFavoriteArea) {
width: 80, _openCardViewer(cards, index, packColor);
height: 80, }
margin: const EdgeInsets.all(8), },
decoration: BoxDecoration( child: Container(
color: packColor.withValues(alpha: 0.1), decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6), color: packColor.withValues(alpha: 0.05),
), border: Border.all(
child: ClipRRect( color: packColor.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(6), width: 1,
child: _buildCardImage(card, 80, 80),
), ),
borderRadius: BorderRadius.circular(8),
), ),
child: Stack(
// Контентц справа children: [
Expanded( Row(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
// Original текст // Изображение слева
if (card.original != null && card.original!.isNotEmpty) Container(
MnemoText( width: 80,
card.original, height: 80,
textStyle: Theme.of(context).textTheme.titleMedium margin: const EdgeInsets.all(8),
?.copyWith( decoration: BoxDecoration(
fontSize: 16, color: packColor.withValues(alpha: 0.1),
fontWeight: FontWeight.w600, borderRadius: BorderRadius.circular(6),
),
maxLines: 1,
textAlign: TextAlign.left,
), ),
child: ClipRRect(
const SizedBox(height: 4), borderRadius: BorderRadius.circular(6),
child: _buildCardImage(card, 80, 80),
// Translation текст
if (card.translation != null &&
card.translation!.isNotEmpty)
MnemoText(
card.translation,
textStyle: Theme.of(context).textTheme.bodyMedium
?.copyWith(
fontSize: 14,
fontWeight: FontWeight.w400,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.7),
),
maxLines: 1,
textAlign: TextAlign.left,
), ),
),
const SizedBox(height: 4), // Контент справа (с резервом под сердце сверху справа)
Expanded(
child: Padding(
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)
MnemoText(
card.original,
textStyle: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
fontSize: 16,
fontWeight: FontWeight.w600,
),
maxLines: 1,
textAlign: TextAlign.left,
),
// Mnemo текст const SizedBox(height: 4),
if (card.mnemo != null && card.mnemo!.isNotEmpty)
MnemoText( // Translation текст
card.mnemo, if (card.translation != null &&
textStyle: Theme.of(context).textTheme.bodySmall card.translation!.isNotEmpty)
?.copyWith( MnemoText(
fontSize: 12, card.translation,
fontWeight: FontWeight.w700, textStyle: Theme.of(context)
color: packColor, .textTheme
), .bodyMedium
maxLines: 1, ?.copyWith(
textAlign: TextAlign.left, fontSize: 14,
fontWeight: FontWeight.w400,
color: Theme.of(
context,
)
.colorScheme
.onSurface
.withValues(alpha: 0.7),
),
maxLines: 1,
textAlign: TextAlign.left,
),
const SizedBox(height: 4),
// Mnemo текст
if (card.mnemo != null && card.mnemo!.isNotEmpty)
MnemoText(
card.mnemo,
textStyle: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
fontSize: 12,
fontWeight: FontWeight.w700,
color: packColor,
),
maxLines: 1,
textAlign: TextAlign.left,
),
],
),
), ),
),
], ],
), ),
),
// Сердце в правом верхнем углу (как в CardViewer)
Positioned(
top: 4,
right: 4,
child: CardFavoriteButton(cardId: card.id, size: 16),
),
],
), ),
),
// Иконки действий справа );
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),
),
);
},
),
),
],
),
),
],
),
),
); );
} }

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/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.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 '../../domain/config/api_config_v2.dart';
import '../../presentation/theme/app_colors.dart'; import '../../presentation/theme/app_colors.dart';
import 'card_favorite_button.dart';
import 'card_voice_controls.dart'; import 'card_voice_controls.dart';
import 'mnemo_text.dart'; import 'mnemo_text.dart';
@ -45,9 +44,10 @@ class _CardViewerState extends State<CardViewer> {
@override @override
void initState() { void initState() {
super.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((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus(); _focusNode.requestFocus();
@ -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() { void _goToPrevious() {
if (widget.cards.isEmpty) return; if (widget.cards.isEmpty) return;
final newIndex = _currentIndex == 0 _pageController.previousPage(
? widget.cards.length - 1 // Циклический переход: с первой на последнюю
: _currentIndex - 1;
_pageController.animateToPage(
newIndex,
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut, curve: Curves.easeInOut,
); );
@ -85,13 +91,8 @@ class _CardViewerState extends State<CardViewer> {
void _goToNext() { void _goToNext() {
if (widget.cards.isEmpty) return; if (widget.cards.isEmpty) return;
final newIndex = _currentIndex == widget.cards.length - 1 _pageController.nextPage(
? 0 // Циклический переход: с последней на первую
: _currentIndex + 1;
_pageController.animateToPage(
newIndex,
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut, curve: Curves.easeInOut,
); );
@ -147,28 +148,40 @@ class _CardViewerState extends State<CardViewer> {
math.max(24.0, cardWidth * 0.15), 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( return Stack(
children: [ children: [
// PageView с карточками // PageView с карточками
PageView.builder( PageView.builder(
controller: _pageController, controller: _pageController,
itemCount: widget.cards.length, itemCount: widget.cards.length + 2, // +2 для бесконечной прокрутки
onPageChanged: (index) { onPageChanged: (index) {
setState(() { // Обработка переходов между дополнительными страницами для бесконечной прокрутки
_currentIndex = 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(); _focusNode.requestFocus();
}, },
itemBuilder: (context, index) { itemBuilder: (context, index) {
final cardIndex = _getCardIndex(index);
return AnimatedBuilder( return AnimatedBuilder(
animation: _pageController, animation: _pageController,
builder: (context, child) { builder: (context, child) {
@ -182,8 +195,8 @@ class _CardViewerState extends State<CardViewer> {
child: Transform.scale( child: Transform.scale(
scale: value, scale: value,
child: _buildCard( child: _buildCard(
widget.cards[index], widget.cards[cardIndex],
index, cardIndex,
packColor, 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( Positioned(
top: 16, top: 16,
@ -287,7 +240,7 @@ class _CardViewerState extends State<CardViewer> {
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: Text( child: Text(
'${_currentIndex + 1} / ${widget.cards.length}', '${_currentCardIndex + 1} / ${widget.cards.length}',
style: TextStyle( style: TextStyle(
color: colorScheme.onSurface, color: colorScheme.onSurface,
fontSize: 16, fontSize: 16,
@ -442,30 +395,33 @@ class _CardSide extends StatelessWidget {
final cardWidth = math.min(screenSize.width * 0.9, cardHeight * 0.66); final cardWidth = math.min(screenSize.width * 0.9, cardHeight * 0.66);
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
return Container( return Hero(
width: cardWidth, tag: 'card_${card.id}',
height: cardHeight, child: Container(
decoration: BoxDecoration( width: cardWidth,
color: colorScheme.surface, height: cardHeight,
borderRadius: BorderRadius.circular(24), decoration: BoxDecoration(
border: Border.all( color: colorScheme.surface,
color: packColor, borderRadius: BorderRadius.circular(24),
width: 3, border: Border.all(
), color: packColor,
boxShadow: [ width: 3,
BoxShadow(
color: colorScheme.shadow.withOpacity(0.3),
blurRadius: 20,
offset: const Offset(0, 10),
), ),
], boxShadow: [
), BoxShadow(
child: ClipRRect( color: colorScheme.shadow.withOpacity(0.3),
borderRadius: BorderRadius.circular(21), blurRadius: 20,
child: isFront offset: const Offset(0, 10),
? _buildFrontLayout() ),
: _buildBackLayout(), ],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(21),
child: isFront
? _buildFrontLayout()
: _buildBackLayout(),
),
), ),
); );
} }
@ -484,9 +440,10 @@ class _CardSide extends StatelessWidget {
child: Stack( child: Stack(
children: [ children: [
Padding( Padding(
// Reserve corner space for the overlay button without // Reserve symmetric corner space for overlay controls
// adding an extra row in the layout. // (voice on the left, favorite on the right) while keeping
padding: const EdgeInsets.only(right: 56), // the text centered.
padding: const EdgeInsets.symmetric(horizontal: 56),
child: Column( child: Column(
children: [ children: [
// Original текст - нормальный цвет для хорошей читаемости // Original текст - нормальный цвет для хорошей читаемости
@ -522,13 +479,18 @@ class _CardSide extends StatelessWidget {
), ),
Positioned( Positioned(
top: 0, top: 0,
right: 0, left: 0,
child: CardVoiceControls( child: CardVoiceControls(
packId: packId, packId: packId,
cardId: card.id, cardId: card.id,
accentColor: packColor, 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'; import '../../../domain/models/game_question.dart';
typedef QuestionAudioPlayback = Future<void> Function(Uri audioUri);
/// Widget for displaying game questions (text, image, audio) /// Widget for displaying game questions (text, image, audio)
class QuestionDisplay extends StatelessWidget { class QuestionDisplay extends StatelessWidget {
const QuestionDisplay({ const QuestionDisplay({
required this.question, required this.question,
this.onPlayAudio,
super.key, super.key,
}); });
final GameQuestion question; final GameQuestion question;
final QuestionAudioPlayback? onPlayAudio;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -115,21 +119,117 @@ class QuestionDisplay extends StatelessWidget {
// Audio button // Audio button
if (audio != null) ...[ if (audio != null) ...[
SizedBox(height: 12.h), SizedBox(height: 12.h),
IconButton( _QuestionAudioButton(
onPressed: () { audioUrl: audio,
// TODO: Implement audio playback onPlayAudio: onPlayAudio,
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Audio playback not implemented yet')),
);
},
icon: Icon(
Icons.volume_up,
size: 32.sp,
color: Theme.of(context).colorScheme.primary,
),
), ),
], ],
], ],
); );
} }
} }
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: 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

@ -6,64 +6,132 @@ import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import '../../domain/config/api_config_v2.dart'; import '../../domain/config/api_config_v2.dart';
import 'mnemo_text.dart'; import 'mnemo_text.dart';
/// Виджет для отображения отдельной карточки в сетке /// Виджет для отображения отдельной карточки в сетке
/// ///
/// Адаптирован под стиль мобильного приложения: /// Адаптирован под стиль мобильного приложения:
/// - Отображает изображение если есть /// - Отображает изображение если есть
/// - Показывает original текст (сверху) /// - Показывает original текст (сверху)
/// - Показывает translation текст (снизу, серым) /// - Показывает translation текст (снизу, серым)
/// - Показывает mnemo текст (совсем внизу) /// - Показывает mnemo текст (совсем внизу)
/// - Иконки действий: сердце (избранное) и глаз (просмотр) /// - Иконка избранного (сердце) в правом верхнем углу
class PackCardItem extends StatelessWidget { class PackCardItem extends StatelessWidget {
const PackCardItem({ const PackCardItem({
required this.card, required this.card,
required this.color, required this.color,
required this.packId, required this.packId,
this.onTap, this.onTap,
this.isFavorite = false, this.isFavorite = false,
this.onToggleFavorite, this.onToggleFavorite,
this.onView, this.onView,
super.key, super.key,
}); });
final GameCardDto card; static const favoriteRootKeyPrefix = 'pack_card_item_favorite_';
final Color color; static const cardRootKeyPrefix = 'pack_card_item_';
final String packId;
final VoidCallback? onTap; final GameCardDto card;
final bool isFavorite; final Color color;
final VoidCallback? onToggleFavorite; final String packId;
final VoidCallback? onView; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final hasImage = card.image != null && card.image!.isNotEmpty; final hasImage = card.image != null && card.image!.isNotEmpty;
return GestureDetector( return LayoutBuilder(
onTap: onTap, builder: (context, constraints) {
child: Container( return GestureDetector(
alignment: Alignment.center, behavior: HitTestBehavior.opaque,
decoration: BoxDecoration( onTapUp: (details) {
color: Theme.of(context).scaffoldBackgroundColor, final local = details.localPosition;
border: Border.all( if (_isInFavoriteArea(local, constraints.maxWidth)) {
color: color.withOpacity(0.3), return;
width: 1, }
), onTap?.call();
borderRadius: BorderRadius.circular(8), },
boxShadow: [ child: Hero(
BoxShadow( tag: 'card_${card.id}',
color: Theme.of(context) child: Container(
.colorScheme key: ValueKey('$cardRootKeyPrefix${card.id}'),
.primary alignment: Alignment.center,
.withOpacity(0.05), decoration: BoxDecoration(
blurRadius: 2.0, color: Theme.of(context).scaffoldBackgroundColor,
border: Border.all(
color: color.withOpacity(0.3),
width: 1,
),
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Theme.of(context).colorScheme.primary.withOpacity(0.05),
blurRadius: 2.0,
),
],
),
clipBehavior: Clip.antiAlias,
child: Stack(
fit: StackFit.expand,
children: [
hasImage
? _buildCardWithImage(context)
: _buildCardTextOnly(context),
Positioned(
top: 4,
right: 4,
child: _buildFavoriteButton(context),
),
],
),
), ),
], ),
), );
clipBehavior: Clip.antiAlias, },
child: hasImage
? _buildCardWithImage(context)
: _buildCardTextOnly(context),
),
); );
} }
@ -74,124 +142,65 @@ import 'mnemo_text.dart';
final cardWidth = constraints.maxWidth; final cardWidth = constraints.maxWidth;
final cardHeight = constraints.maxHeight; final cardHeight = constraints.maxHeight;
return Stack( return Column(
children: [ children: [
Column( // Original и Translation сверху (с резервом под сердце справа)
children: [ Padding(
// Original и Translation сверху padding: const EdgeInsets.fromLTRB(4, 4, 28, 4),
Padding( child: Column(
padding: const EdgeInsets.symmetric( children: [
horizontal: 4.0, if (card.original != null && card.original!.isNotEmpty)
vertical: 4.0, MnemoText(
), card.original,
child: Column( textStyle: Theme.of(context).textTheme.bodyMedium
children: [ ?.copyWith(
if (card.original != null && card.original!.isNotEmpty) fontSize: max(10, cardWidth * 0.12),
MnemoText( fontWeight: FontWeight.w600,
card.original,
textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontSize: max(10, cardWidth * 0.12),
fontWeight: FontWeight.w600,
),
maxLines: 1,
textAlign: TextAlign.center,
),
if (card.translation != null &&
card.translation!.isNotEmpty)
MnemoText(
card.translation,
textStyle: Theme.of(context).textTheme.bodySmall?.copyWith(
fontSize: max(8, cardWidth * 0.09),
fontWeight: FontWeight.w700,
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.5),
),
maxLines: 1,
textAlign: TextAlign.center,
),
],
),
),
// Изображение в центре
Expanded(
child: Padding(
padding: const EdgeInsets.all(4.0),
child: _buildImage(context, cardWidth, cardHeight),
),
),
// Mnemo снизу
if (card.mnemo != null && card.mnemo!.isNotEmpty)
Padding(
padding: const EdgeInsets.all(4.0),
child: MnemoText(
card.mnemo,
textStyle: Theme.of(context).textTheme.bodySmall?.copyWith(
fontSize: max(8, cardWidth * 0.1),
fontWeight: FontWeight.w700,
), ),
maxLines: 2, maxLines: 1,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), if (card.translation != null &&
], card.translation!.isNotEmpty)
), MnemoText(
card.translation,
// Иконки действий в правом нижнем углу textStyle: Theme.of(context).textTheme.bodySmall?.copyWith(
Positioned( fontSize: max(8, cardWidth * 0.09),
bottom: 4, fontWeight: FontWeight.w700,
right: 4, color: Theme.of(context)
child: Builder( .colorScheme
builder: (context) { .onSurface
final colorScheme = Theme.of(context).colorScheme; .withOpacity(0.5),
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( maxLines: 1,
isFavorite textAlign: TextAlign.center,
? 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),
),
),
),
],
);
},
), ),
), ),
// Изображение в центре
Expanded(
child: Padding(
padding: const EdgeInsets.all(4.0),
child: _buildImage(context, cardWidth, cardHeight),
),
),
// Mnemo снизу
if (card.mnemo != null && card.mnemo!.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 28, 4),
child: MnemoText(
card.mnemo,
textStyle: Theme.of(context).textTheme.bodySmall?.copyWith(
fontSize: max(8, cardWidth * 0.1),
fontWeight: FontWeight.w700,
),
maxLines: 2,
textAlign: TextAlign.center,
),
),
], ],
); );
}, },
@ -201,7 +210,7 @@ import 'mnemo_text.dart';
/// Карточка только с текстом (без изображения) /// Карточка только с текстом (без изображения)
Widget _buildCardTextOnly(BuildContext context) { Widget _buildCardTextOnly(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.fromLTRB(8, 8, 28, 8),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [

View file

@ -114,19 +114,44 @@ class _ControlButton extends StatelessWidget {
border: Border.all(color: borderColor, width: 1), border: Border.all(color: borderColor, width: 1),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Row( child: LayoutBuilder(
mainAxisAlignment: MainAxisAlignment.center, builder: (context, constraints) {
children: [ final textStyle = theme.textTheme.bodyMedium?.copyWith(
iconWidget, color: textColor,
const SizedBox(width: 8), fontWeight: isActive ? FontWeight.w600 : FontWeight.w400,
Text( );
label,
style: theme.textTheme.bodyMedium?.copyWith( // Измеряем ширину текста
color: textColor, final textPainter = TextPainter(
fontWeight: isActive ? FontWeight.w600 : FontWeight.w400, 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,11 +51,15 @@ class PackDetailsSidebar extends StatelessWidget {
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Expanded(
'Проверка знаний', child: Text(
style: Theme.of(context).textTheme.titleMedium?.copyWith( 'Проверка знаний',
fontWeight: FontWeight.w600, maxLines: 1,
), overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
), ),
], ],
), ),

View file

@ -109,6 +109,7 @@ flutter:
assets: assets:
- icons/ - icons/
- assets/images/
fonts: fonts:
- family: Nunito - 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', () { group('GamePage', () {
testWidgets('should display preparing state', (tester) async { testWidgets('should display preparing state', (tester) async {
await testsStateManager.setStateForTest( await testsStateManager.setStateForTest(
@ -206,6 +193,50 @@ void main() {
expect(testsStateManager.resumeCalls, equals(1)); 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 { testWidgets('should display active game state', (tester) async {
final questions = [ final questions = [
GameQuestion.multipleChoice( GameQuestion.multipleChoice(
@ -290,7 +321,7 @@ void main() {
expect(material.surfaceTintColor, theme.colorScheme.surfaceTint); 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 = [ final questions = [
GameQuestion.multipleChoice( GameQuestion.multipleChoice(
MultipleChoiceQuestion( MultipleChoiceQuestion(
@ -319,6 +350,7 @@ void main() {
await tester.pump(); await tester.pump();
expect(find.byIcon(Icons.close), findsOneWidget); expect(find.byIcon(Icons.close), findsOneWidget);
expect(find.byTooltip('Exit game'), findsNothing);
}); });
testWidgets('starts session when not active', (tester) async { testWidgets('starts session when not active', (tester) async {
@ -356,7 +388,7 @@ void main() {
expect(find.text('Back'), findsOneWidget); expect(find.text('Back'), findsOneWidget);
}); });
testWidgets('should show exit confirmation dialog', (tester) async { testWidgets('does not show exit confirmation dialog', (tester) async {
final questions = [ final questions = [
GameQuestion.multipleChoice( GameQuestion.multipleChoice(
MultipleChoiceQuestion( MultipleChoiceQuestion(
@ -385,12 +417,12 @@ void main() {
await tester.pump(); await tester.pump();
await tester.tap(find.byIcon(Icons.close)); 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( expect(
find.text('Are you sure you want to exit? Your progress will be lost.'), 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/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/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/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/yx_scope.dart';
import 'package:yx_scope_flutter/yx_scope_flutter.dart'; import 'package:yx_scope_flutter/yx_scope_flutter.dart';
@ -105,5 +106,79 @@ void main() {
expect(find.text('Test Pack'), findsWidgets); 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/material.dart';
import 'package:flutter_test/flutter_test.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_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_voice_controls.dart';
import 'package:mnemo_cards_web_v2/presentation/widgets/card_viewer.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() { void main() {
const cards = [ const cards = [
@ -32,17 +40,25 @@ void main() {
Future<void> _pumpViewer( Future<void> _pumpViewer(
WidgetTester tester, { WidgetTester tester, {
required int initialIndex, required int initialIndex,
ScopeStateHolder<UserScope?>? userScopeHolder,
}) async { }) async {
await tester.pumpWidget( final app = MaterialApp(
MaterialApp( home: CardViewer(
home: CardViewer( cards: cards,
cards: cards, packId: 'pack-1',
packId: 'pack-1', initialIndex: initialIndex,
initialIndex: initialIndex, packColor: Colors.blue,
packColor: Colors.blue,
),
), ),
); );
await tester.pumpWidget(
userScopeHolder == null
? app
: ScopeProvider<UserScope>(
holder: userScopeHolder,
child: app,
),
);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
} }
@ -129,4 +145,112 @@ void main() {
findsOneWidget, 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( MultipleChoiceQuestion(
id: 'q1', id: 'q1',
question: 'Listen and choose', question: 'Listen and choose',
audio: 'sound.mp3', audio: 'https://example.com/sound.mp3',
options: ['A', 'B'], options: ['A', 'B'],
correctAnswer: 'A', correctAnswer: 'A',
word: 'sound', word: 'sound',
@ -151,6 +151,36 @@ void main() {
expect(find.byIcon(Icons.volume_up), findsOneWidget); 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 { testWidgets('should handle image loading error gracefully', (tester) async {
final question = GameQuestion.multipleChoice( final question = GameQuestion.multipleChoice(
MultipleChoiceQuestion( MultipleChoiceQuestion(

View file

@ -65,8 +65,12 @@ void main() {
), ),
); );
// Находим кнопку перемешивания по иконке shuffle
final shuffleIcon = find.byIcon(Icons.shuffle);
expect(shuffleIcon, findsOneWidget);
final shuffleGesture = find.ancestor( final shuffleGesture = find.ancestor(
of: find.text('Перемешать'), of: shuffleIcon,
matching: find.byType(GestureDetector), matching: find.byType(GestureDetector),
); );
expect(shuffleGesture, findsOneWidget); expect(shuffleGesture, findsOneWidget);
@ -85,6 +89,69 @@ void main() {
expect(border.top.color, AppColors.borderGray); 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() {} void _noop() {}