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:
|
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
|
||||||
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)
|
- 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
17
TODO.md
|
|
@ -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*
|
||||||
|
|
|
||||||
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 '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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -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),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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/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,8 +44,9 @@ 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((_) {
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
@ -86,12 +92,7 @@ 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,
|
||||||
|
|
@ -443,29 +396,32 @@ class _CardSide extends StatelessWidget {
|
||||||
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),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,7 @@ flutter:
|
||||||
|
|
||||||
assets:
|
assets:
|
||||||
- icons/
|
- icons/
|
||||||
|
- assets/images/
|
||||||
|
|
||||||
fonts:
|
fonts:
|
||||||
- family: Nunito
|
- 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', () {
|
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,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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() {}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue