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
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:
parent
910986fadc
commit
a50fb4110a
21 changed files with 1201 additions and 537 deletions
|
|
@ -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
|
||||
17
PROGRESS.md
17
PROGRESS.md
|
|
@ -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
17
TODO.md
|
|
@ -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*
|
||||
|
|
|
|||
95
mnemo_cards_admin/src/components/BulkCardEditor.test.tsx
Normal file
95
mnemo_cards_admin/src/components/BulkCardEditor.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
BIN
mnemo_cards_web_v2/assets/images/el_sol.png
Normal file
BIN
mnemo_cards_web_v2/assets/images/el_sol.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 640 KiB |
BIN
mnemo_cards_web_v2/assets/images/la_luna.png
Normal file
BIN
mnemo_cards_web_v2/assets/images/la_luna.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 232 KiB |
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,158 +692,132 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
|
|||
Color packColor,
|
||||
) {
|
||||
final card = cards[index];
|
||||
return GestureDetector(
|
||||
onTap: () => _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),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Изображение слева
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
margin: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: packColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: _buildCardImage(card, 80, 80),
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
const favoriteHitSize = 56.0;
|
||||
|
||||
return GestureDetector(
|
||||
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,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
|
||||
// Контентц справа
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
child: Stack(
|
||||
children: [
|
||||
Row(
|
||||
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,
|
||||
// Изображение слева
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
margin: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: packColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// 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,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: _buildCardImage(card, 80, 80),
|
||||
),
|
||||
),
|
||||
|
||||
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 текст
|
||||
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,
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// 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),
|
||||
|
||||
// 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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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) {
|
||||
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();
|
||||
},
|
||||
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,29 +396,32 @@ class _CardSide extends StatelessWidget {
|
|||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Container(
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: packColor,
|
||||
width: 3,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colorScheme.shadow.withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
return Hero(
|
||||
tag: 'card_${card.id}',
|
||||
child: Container(
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: packColor,
|
||||
width: 3,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(21),
|
||||
child: isFront
|
||||
? _buildFrontLayout()
|
||||
: _buildBackLayout(),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colorScheme.shadow.withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(21),
|
||||
child: isFront
|
||||
? _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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,21 +119,117 @@ 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')),
|
||||
);
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.volume_up,
|
||||
size: 32.sp,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
_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: 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,64 +6,132 @@ import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
|||
import '../../domain/config/api_config_v2.dart';
|
||||
import 'mnemo_text.dart';
|
||||
|
||||
/// Виджет для отображения отдельной карточки в сетке
|
||||
///
|
||||
/// Адаптирован под стиль мобильного приложения:
|
||||
/// - Отображает изображение если есть
|
||||
/// - Показывает original текст (сверху)
|
||||
/// - Показывает translation текст (снизу, серым)
|
||||
/// - Показывает mnemo текст (совсем внизу)
|
||||
/// - Иконки действий: сердце (избранное) и глаз (просмотр)
|
||||
class PackCardItem extends StatelessWidget {
|
||||
const PackCardItem({
|
||||
required this.card,
|
||||
required this.color,
|
||||
required this.packId,
|
||||
this.onTap,
|
||||
this.isFavorite = false,
|
||||
this.onToggleFavorite,
|
||||
this.onView,
|
||||
super.key,
|
||||
});
|
||||
/// Виджет для отображения отдельной карточки в сетке
|
||||
///
|
||||
/// Адаптирован под стиль мобильного приложения:
|
||||
/// - Отображает изображение если есть
|
||||
/// - Показывает original текст (сверху)
|
||||
/// - Показывает translation текст (снизу, серым)
|
||||
/// - Показывает mnemo текст (совсем внизу)
|
||||
/// - Иконка избранного (сердце) в правом верхнем углу
|
||||
class PackCardItem extends StatelessWidget {
|
||||
const PackCardItem({
|
||||
required this.card,
|
||||
required this.color,
|
||||
required this.packId,
|
||||
this.onTap,
|
||||
this.isFavorite = false,
|
||||
this.onToggleFavorite,
|
||||
this.onView,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final GameCardDto card;
|
||||
final Color color;
|
||||
final String packId;
|
||||
final VoidCallback? onTap;
|
||||
final bool isFavorite;
|
||||
final VoidCallback? onToggleFavorite;
|
||||
final VoidCallback? onView;
|
||||
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 GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
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,
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return GestureDetector(
|
||||
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,
|
||||
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 cardHeight = constraints.maxHeight;
|
||||
|
||||
return Stack(
|
||||
return Column(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
// Original и Translation сверху
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4.0,
|
||||
vertical: 4.0,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
if (card.original != null && card.original!.isNotEmpty)
|
||||
MnemoText(
|
||||
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,
|
||||
// Original и Translation сверху (с резервом под сердце справа)
|
||||
Padding(
|
||||
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(
|
||||
fontSize: max(10, cardWidth * 0.12),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 2,
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Иконки действий в правом нижнем углу
|
||||
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),
|
||||
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),
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
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.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) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 28, 8),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
color: textColor,
|
||||
fontWeight: isActive ? FontWeight.w600 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -51,11 +51,15 @@ class PackDetailsSidebar extends StatelessWidget {
|
|||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Проверка знаний',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Проверка знаний',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ flutter:
|
|||
|
||||
assets:
|
||||
- icons/
|
||||
- assets/images/
|
||||
|
||||
fonts:
|
||||
- family: Nunito
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,17 +40,25 @@ void main() {
|
|||
Future<void> _pumpViewer(
|
||||
WidgetTester tester, {
|
||||
required int initialIndex,
|
||||
ScopeStateHolder<UserScope?>? userScopeHolder,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: CardViewer(
|
||||
cards: cards,
|
||||
packId: 'pack-1',
|
||||
initialIndex: initialIndex,
|
||||
packColor: Colors.blue,
|
||||
),
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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() {}
|
||||
|
|
|
|||
Loading…
Reference in a new issue