fixes
This commit is contained in:
parent
654b301df7
commit
4e07213e20
13 changed files with 981 additions and 306 deletions
|
|
@ -25,6 +25,12 @@ class InputButtonsQuestionGenerator implements QuestionGenerator {
|
|||
InputButtonsQuestionType.translation_and_image_original,
|
||||
};
|
||||
|
||||
/// Convert image ID to proper URL path
|
||||
String? _imageIdToUrl(String? imageId) {
|
||||
if (imageId == null || data.packId == null) return null;
|
||||
return '/api/v2/packs/${data.packId}/cards/$imageId/image';
|
||||
}
|
||||
|
||||
InputButtonsQuestionType _getRandomQuestionType() {
|
||||
final types = (possibleTypes?.toList() ?? InputButtonsQuestionType.values);
|
||||
return types[random.nextInt(types.length)];
|
||||
|
|
@ -47,7 +53,9 @@ class InputButtonsQuestionGenerator implements QuestionGenerator {
|
|||
final questionType = type ?? _getRandomQuestionType();
|
||||
final questionText =
|
||||
questionType.translationQuestion ? answerItem.translation : null;
|
||||
final questionImage = questionType.imageQuestion ? answerItem.image : null;
|
||||
final questionImage = questionType.imageQuestion
|
||||
? _imageIdToUrl(answerItem.image)
|
||||
: null;
|
||||
|
||||
String? questionAudio = null;
|
||||
if (questionType.audioQuestion) {
|
||||
|
|
@ -108,6 +116,7 @@ class InputButtonsQuestionGenerator implements QuestionGenerator {
|
|||
);
|
||||
}
|
||||
|
||||
|
||||
Future<InputButtonsTestQuestionBody> verbTestQuestion(
|
||||
TestDataItem answerCard) async {
|
||||
final ends = ['o', 'as', 'a', 'a', 'a', 'amos', 'ais', 'an', 'an', 'an'];
|
||||
|
|
|
|||
|
|
@ -25,6 +25,12 @@ class SimpleQuestionGenerator implements QuestionGenerator {
|
|||
SimpleQuestionType.audio_images,
|
||||
};
|
||||
|
||||
/// Convert image ID to proper URL path
|
||||
String? _imageIdToUrl(String? imageId) {
|
||||
if (imageId == null || data.packId == null) return null;
|
||||
return '/api/v2/packs/${data.packId}/cards/$imageId/image';
|
||||
}
|
||||
|
||||
SimpleQuestionType _getRandomQuestionType() {
|
||||
final types = (possibleTypes?.toList() ?? SimpleQuestionType.values);
|
||||
return types[random.nextInt(types.length)];
|
||||
|
|
@ -46,7 +52,9 @@ class SimpleQuestionGenerator implements QuestionGenerator {
|
|||
: questionType.translationQuestion
|
||||
? answerCard.translation
|
||||
: null;
|
||||
final questionImage = questionType.imageQuestion ? answerCard.image : null;
|
||||
final questionImage = questionType.imageQuestion
|
||||
? _imageIdToUrl(answerCard.image)
|
||||
: null;
|
||||
String? questionAudio = null;
|
||||
if (questionType.audioQuestion) {
|
||||
if (questionType.translationAnswers || questionType.imagesAnswers) {
|
||||
|
|
@ -79,12 +87,13 @@ class SimpleQuestionGenerator implements QuestionGenerator {
|
|||
)
|
||||
else if (questionType.imagesAnswers)
|
||||
...answers.map(
|
||||
(e) => TestButtonDto.image(e.id, e.image),
|
||||
(e) => TestButtonDto.image(e.id, _imageIdToUrl(e.image)),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Future<SimpleTestQuestionBody> verbTestQuestion(
|
||||
TestDataItem answerCard, {
|
||||
bool withAudio = true,
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
1../mnemo_cards/fonts/Nunito-VariableFont_wght.ttf
asset1../mnemo_cards/fonts/Nunito-VariableFont_wght.ttf
icons/ad.webp
asset
icons/ad.webpicons/back.webp
asseticons/back.webpicons/cards.webp
asseticons/cards.webpicons/chat.webp
asseticons/chat.webpicons/clock.webp
asseticons/clock.webpicons/crown.webp
asseticons/crown.webpicons/down.webp
asseticons/down.webpicons/download.webp
asseticons/download.webpicons/exit.webp
asseticons/exit.webpicons/exit2.webp
asseticons/exit2.webpicons/exit3.webp
asseticons/exit3.webpicons/gift.webp
asseticons/gift.webpicons/google.webp
asseticons/google.webpicons/heart.webp
asseticons/heart.webpicons/heart_fill.webp
asseticons/heart_fill.webpicons/lock.webp
asseticons/lock.webpicons/mic_off.webp
asseticons/mic_off.webpicons/mic_on.webp
asseticons/mic_on.webpicons/next.webp
asseticons/next.webpicons/profile.webp
asseticons/profile.webpicons/settings.webp
asseticons/settings.webpicons/shuffle.webp
asseticons/shuffle.webpicons/skull.webp
asseticons/skull.webpicons/small_circle.webp
asseticons/small_circle.webpicons/sound_off.webp
asseticons/sound_off.webpicons/sound_on.webp
asseticons/sound_on.webpicons/telegram.webp
asseticons/telegram.webp
icons/tg.webp
asset
icons/tg.webpicons/view.webp
asseticons/view.webp2packages/cupertino_icons/assets/CupertinoIcons.ttf
asset2packages/cupertino_icons/assets/CupertinoIcons.ttf
|
||||
"fonts/Nunito-VariableFont_wght.ttf
asset"fonts/Nunito-VariableFont_wght.ttf
icons/ad.webp
asset
icons/ad.webpicons/back.webp
asseticons/back.webpicons/cards.webp
asseticons/cards.webpicons/chat.webp
asseticons/chat.webpicons/clock.webp
asseticons/clock.webpicons/crown.webp
asseticons/crown.webpicons/down.webp
asseticons/down.webpicons/download.webp
asseticons/download.webpicons/exit.webp
asseticons/exit.webpicons/exit2.webp
asseticons/exit2.webpicons/exit3.webp
asseticons/exit3.webpicons/gift.webp
asseticons/gift.webpicons/google.webp
asseticons/google.webpicons/heart.webp
asseticons/heart.webpicons/heart_fill.webp
asseticons/heart_fill.webpicons/lock.webp
asseticons/lock.webpicons/mic_off.webp
asseticons/mic_off.webpicons/mic_on.webp
asseticons/mic_on.webpicons/next.webp
asseticons/next.webpicons/profile.webp
asseticons/profile.webpicons/settings.webp
asseticons/settings.webpicons/shuffle.webp
asseticons/shuffle.webpicons/skull.webp
asseticons/skull.webpicons/small_circle.webp
asseticons/small_circle.webpicons/sound_off.webp
asseticons/sound_off.webpicons/sound_on.webp
asseticons/sound_on.webpicons/telegram.webp
asseticons/telegram.webp
icons/tg.webp
asset
icons/tg.webpicons/view.webp
asseticons/view.webp2packages/cupertino_icons/assets/CupertinoIcons.ttf
asset2packages/cupertino_icons/assets/CupertinoIcons.ttf
|
||||
|
|
@ -1 +1 @@
|
|||
{"../mnemo_cards/fonts/Nunito-VariableFont_wght.ttf":["../mnemo_cards/fonts/Nunito-VariableFont_wght.ttf"],"icons/ad.webp":["icons/ad.webp"],"icons/back.webp":["icons/back.webp"],"icons/cards.webp":["icons/cards.webp"],"icons/chat.webp":["icons/chat.webp"],"icons/clock.webp":["icons/clock.webp"],"icons/crown.webp":["icons/crown.webp"],"icons/down.webp":["icons/down.webp"],"icons/download.webp":["icons/download.webp"],"icons/exit.webp":["icons/exit.webp"],"icons/exit2.webp":["icons/exit2.webp"],"icons/exit3.webp":["icons/exit3.webp"],"icons/gift.webp":["icons/gift.webp"],"icons/google.webp":["icons/google.webp"],"icons/heart.webp":["icons/heart.webp"],"icons/heart_fill.webp":["icons/heart_fill.webp"],"icons/lock.webp":["icons/lock.webp"],"icons/mic_off.webp":["icons/mic_off.webp"],"icons/mic_on.webp":["icons/mic_on.webp"],"icons/next.webp":["icons/next.webp"],"icons/profile.webp":["icons/profile.webp"],"icons/settings.webp":["icons/settings.webp"],"icons/shuffle.webp":["icons/shuffle.webp"],"icons/skull.webp":["icons/skull.webp"],"icons/small_circle.webp":["icons/small_circle.webp"],"icons/sound_off.webp":["icons/sound_off.webp"],"icons/sound_on.webp":["icons/sound_on.webp"],"icons/telegram.webp":["icons/telegram.webp"],"icons/tg.webp":["icons/tg.webp"],"icons/view.webp":["icons/view.webp"],"packages/cupertino_icons/assets/CupertinoIcons.ttf":["packages/cupertino_icons/assets/CupertinoIcons.ttf"]}
|
||||
{"fonts/Nunito-VariableFont_wght.ttf":["fonts/Nunito-VariableFont_wght.ttf"],"icons/ad.webp":["icons/ad.webp"],"icons/back.webp":["icons/back.webp"],"icons/cards.webp":["icons/cards.webp"],"icons/chat.webp":["icons/chat.webp"],"icons/clock.webp":["icons/clock.webp"],"icons/crown.webp":["icons/crown.webp"],"icons/down.webp":["icons/down.webp"],"icons/download.webp":["icons/download.webp"],"icons/exit.webp":["icons/exit.webp"],"icons/exit2.webp":["icons/exit2.webp"],"icons/exit3.webp":["icons/exit3.webp"],"icons/gift.webp":["icons/gift.webp"],"icons/google.webp":["icons/google.webp"],"icons/heart.webp":["icons/heart.webp"],"icons/heart_fill.webp":["icons/heart_fill.webp"],"icons/lock.webp":["icons/lock.webp"],"icons/mic_off.webp":["icons/mic_off.webp"],"icons/mic_on.webp":["icons/mic_on.webp"],"icons/next.webp":["icons/next.webp"],"icons/profile.webp":["icons/profile.webp"],"icons/settings.webp":["icons/settings.webp"],"icons/shuffle.webp":["icons/shuffle.webp"],"icons/skull.webp":["icons/skull.webp"],"icons/small_circle.webp":["icons/small_circle.webp"],"icons/sound_off.webp":["icons/sound_off.webp"],"icons/sound_on.webp":["icons/sound_on.webp"],"icons/telegram.webp":["icons/telegram.webp"],"icons/tg.webp":["icons/tg.webp"],"icons/view.webp":["icons/view.webp"],"packages/cupertino_icons/assets/CupertinoIcons.ttf":["packages/cupertino_icons/assets/CupertinoIcons.ttf"]}
|
||||
|
|
@ -1 +1 @@
|
|||
[{"family":"MaterialIcons","fonts":[{"asset":"fonts/MaterialIcons-Regular.otf"}]},{"family":"Nunito","fonts":[{"asset":"../mnemo_cards/fonts/Nunito-VariableFont_wght.ttf"}]},{"family":"packages/cupertino_icons/CupertinoIcons","fonts":[{"asset":"packages/cupertino_icons/assets/CupertinoIcons.ttf"}]}]
|
||||
[{"family":"MaterialIcons","fonts":[{"asset":"fonts/MaterialIcons-Regular.otf"}]},{"family":"Nunito","fonts":[{"asset":"fonts/Nunito-VariableFont_wght.ttf"}]},{"family":"packages/cupertino_icons/CupertinoIcons","fonts":[{"asset":"packages/cupertino_icons/assets/CupertinoIcons.ttf"}]}]
|
||||
|
|
@ -160,7 +160,7 @@ class _AppInitializerState extends State<_AppInitializer> {
|
|||
|
||||
if (!_isInitialized) {
|
||||
log('Showing loading screen', name: 'App');
|
||||
return const _LoadingScreen(message: 'Загрузка...');
|
||||
return const _LoadingScreen(message: 'Loading...');
|
||||
}
|
||||
|
||||
log('Building main app', name: 'App');
|
||||
|
|
@ -207,7 +207,7 @@ class _LoadingScreen extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
backgroundColor: Theme.of(context).isDark ? AppTheme.dark.backgroundColor : AppTheme.light.backgroundColor,
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
|
|
|
|||
|
|
@ -9,26 +9,7 @@ import '../../../di/app_scope/app_scope_container.dart';
|
|||
import '../../../presentation/widgets/loading_view.dart';
|
||||
import '../../../presentation/widgets/error_view.dart';
|
||||
|
||||
/// Simple test result model
|
||||
class TestResult {
|
||||
final String testId;
|
||||
final int correctAnswers;
|
||||
final int incorrectAnswers;
|
||||
final int totalQuestions;
|
||||
final int timeTaken;
|
||||
final DateTime completedAt;
|
||||
|
||||
const TestResult({
|
||||
required this.testId,
|
||||
required this.correctAnswers,
|
||||
required this.incorrectAnswers,
|
||||
required this.totalQuestions,
|
||||
required this.timeTaken,
|
||||
required this.completedAt,
|
||||
});
|
||||
}
|
||||
|
||||
/// Test page for displaying and taking a test
|
||||
/// Test page for displaying test info and launching the game
|
||||
class TestPage extends StatefulWidget {
|
||||
const TestPage({
|
||||
required this.testId,
|
||||
|
|
@ -45,13 +26,185 @@ class _TestPageState extends State<TestPage> {
|
|||
TestDto? _test;
|
||||
bool _isLoading = true;
|
||||
String? _errorMessage;
|
||||
|
||||
// Test taking state
|
||||
bool _isTestStarted = false;
|
||||
int _currentQuestionIndex = 0;
|
||||
final Map<int, String> _userAnswers = {};
|
||||
bool _isTestCompleted = false;
|
||||
TestResult? _testResult;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadTest();
|
||||
}
|
||||
|
||||
Future<void> _loadTest() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final appScope = ScopeProvider.of<AppScopeContainer>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final userScope = appScope?.userScopeHolder.scope;
|
||||
if (userScope == null) {
|
||||
throw Exception('Scope not available');
|
||||
}
|
||||
|
||||
log('Loading test: ${widget.testId}', name: 'TestPage');
|
||||
final test = await userScope.testsModule.testManager.loadTest(widget.testId);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_test = test;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e, s) {
|
||||
log(
|
||||
'Error loading test',
|
||||
error: e,
|
||||
stackTrace: s,
|
||||
name: 'TestPage',
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMessage = 'Failed to load test: ${e.toString()}';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_test?.name ?? 'Test'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
),
|
||||
body: _buildBody(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_isLoading) {
|
||||
return const LoadingView(message: 'Loading test...');
|
||||
}
|
||||
|
||||
if (_errorMessage != null) {
|
||||
return ErrorView(
|
||||
title: 'Failed to load test',
|
||||
message: _errorMessage!,
|
||||
onRetry: _loadTest,
|
||||
);
|
||||
}
|
||||
|
||||
if (_test == null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 64, color: Colors.grey),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Test not found'),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadTest,
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return _buildTestIntro();
|
||||
}
|
||||
|
||||
Widget _buildTestIntro() {
|
||||
final test = _test!;
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
test.name,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Test Information',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildInfoRow(Icons.quiz, 'Questions', '${test.questions.length}'),
|
||||
const SizedBox(height: 8),
|
||||
_buildInfoRow(Icons.timer, 'Estimated Time', '${test.questions.length * 2} minutes'),
|
||||
const SizedBox(height: 8),
|
||||
_buildInfoRow(Icons.help_outline, 'Type', 'Interactive Game'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Instructions',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'• Answer each question as it appears\n'
|
||||
'• Get instant feedback on your answers\n'
|
||||
'• Complete all questions to see your results\n'
|
||||
'• Your progress and statistics will be tracked',
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => _playGame(context),
|
||||
icon: const Icon(Icons.games),
|
||||
label: const Text('Start Test'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
textStyle: const TextStyle(fontSize: 18),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRow(IconData icon, String label, String value) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'$label: ',
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
Text(value),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _playGame(BuildContext context) {
|
||||
// Navigate to the game page
|
||||
context.go('/game/${widget.testId}');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
|
|||
|
|
@ -27,103 +27,109 @@ class PackCard extends StatelessWidget {
|
|||
final theme = Theme.of(context);
|
||||
final packColor = pack.color?.asColor ?? theme.colorScheme.primary;
|
||||
|
||||
return Semantics(
|
||||
label: 'Pack: ${pack.title}. Tap to view details.',
|
||||
button: true,
|
||||
child: Stack(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (pack.isAvailable) {
|
||||
context.push('/pack/${pack.id}');
|
||||
} else {
|
||||
context.push('/purchase/${pack.id}');
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
height: cardHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
border: Border.all(color: packColor.withOpacity(0.9), width: 1),
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
),
|
||||
clipBehavior: Clip.antiAliasWithSaveLayer,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
// Квадратное изображение слева
|
||||
_buildPackImage(context, packColor),
|
||||
return Hero(
|
||||
tag: 'pack-hero-${pack.id}',
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Semantics(
|
||||
label: 'Pack: ${pack.title}. Tap to view details.',
|
||||
button: true,
|
||||
child: Stack(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (pack.isAvailable) {
|
||||
context.push('/pack/${pack.id}');
|
||||
} else {
|
||||
context.push('/purchase/${pack.id}');
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
height: cardHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
border: Border.all(color: packColor.withOpacity(0.9), width: 1),
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
),
|
||||
clipBehavior: Clip.antiAliasWithSaveLayer,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
// Квадратное изображение слева
|
||||
_buildPackImage(context, packColor),
|
||||
|
||||
const SizedBox(width: 4),
|
||||
const SizedBox(width: 4),
|
||||
|
||||
// Информация о паке справа
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Заголовок
|
||||
Flexible(
|
||||
child: Text(
|
||||
pack.title,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontSize: 24,
|
||||
height: 0.9,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
|
||||
// Подзаголовок (если есть)
|
||||
if (pack.subtitle != null &&
|
||||
pack.subtitle!.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Flexible(
|
||||
child: Text(
|
||||
pack.subtitle!,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w300,
|
||||
fontSize: 16,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Количество карточек
|
||||
Row(
|
||||
// Информация о паке справа
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
pack.cards.toString(),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w200,
|
||||
fontSize: 15,
|
||||
// Заголовок
|
||||
Flexible(
|
||||
child: Text(
|
||||
pack.title,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontSize: 24,
|
||||
height: 0.9,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.style,
|
||||
size: 18,
|
||||
color: theme.colorScheme.primary,
|
||||
|
||||
// Подзаголовок (если есть)
|
||||
if (pack.subtitle != null &&
|
||||
pack.subtitle!.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Flexible(
|
||||
child: Text(
|
||||
pack.subtitle!,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w300,
|
||||
fontSize: 16,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Количество карточек
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
pack.cards.toString(),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w200,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.style,
|
||||
size: 18,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Pack tip overlay
|
||||
if (pack.tip != null) _buildPackTip(context, packColor),
|
||||
],
|
||||
),
|
||||
// Pack tip overlay
|
||||
if (pack.tip != null) _buildPackTip(context, packColor),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -156,29 +162,26 @@ class PackCard extends StatelessWidget {
|
|||
? MemoryImage(base64Decode(pack.imageBase64!))
|
||||
: null);
|
||||
|
||||
return Hero(
|
||||
tag: 'pack-${pack.id}',
|
||||
child: Container(
|
||||
width: imageSize,
|
||||
height: imageSize,
|
||||
decoration: BoxDecoration(
|
||||
color: packColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
image: imageProvider != null
|
||||
? DecorationImage(image: imageProvider, fit: BoxFit.cover)
|
||||
: null,
|
||||
),
|
||||
// Показываем иконку только если нет изображения
|
||||
child: imageProvider == null
|
||||
? Center(
|
||||
child: Icon(
|
||||
Icons.collections_bookmark,
|
||||
size: 40,
|
||||
color: packColor,
|
||||
),
|
||||
)
|
||||
return Container(
|
||||
width: imageSize,
|
||||
height: imageSize,
|
||||
decoration: BoxDecoration(
|
||||
color: packColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
image: imageProvider != null
|
||||
? DecorationImage(image: imageProvider, fit: BoxFit.cover)
|
||||
: null,
|
||||
),
|
||||
// Показываем иконку только если нет изображения
|
||||
child: imageProvider == null
|
||||
? Center(
|
||||
child: Icon(
|
||||
Icons.collections_bookmark,
|
||||
size: 40,
|
||||
color: packColor,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,93 +23,99 @@ class PackCardVertical extends StatelessWidget {
|
|||
final theme = Theme.of(context);
|
||||
final packColor = pack.color?.asColor ?? theme.colorScheme.primary;
|
||||
|
||||
return Semantics(
|
||||
label: 'Pack: ${pack.title}. Tap to view details.',
|
||||
button: true,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if (pack.isAvailable) {
|
||||
context.push('/pack/${pack.id}');
|
||||
} else {
|
||||
context.push('/purchase/${pack.id}');
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
border: Border.all(color: packColor.withOpacity(0.9), width: 1),
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
),
|
||||
clipBehavior: Clip.antiAliasWithSaveLayer,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Изображение сверху
|
||||
Flexible(flex: 3, child: _buildPackImage(context, packColor)),
|
||||
Flexible(
|
||||
flex: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
return Hero(
|
||||
tag: 'pack-hero-${pack.id}',
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Semantics(
|
||||
label: 'Pack: ${pack.title}. Tap to view details.',
|
||||
button: true,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if (pack.isAvailable) {
|
||||
context.push('/pack/${pack.id}');
|
||||
} else {
|
||||
context.push('/purchase/${pack.id}');
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
border: Border.all(color: packColor.withOpacity(0.9), width: 1),
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
),
|
||||
clipBehavior: Clip.antiAliasWithSaveLayer,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Изображение сверху
|
||||
Flexible(flex: 3, child: _buildPackImage(context, packColor)),
|
||||
Flexible(
|
||||
flex: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Заголовок
|
||||
Text(
|
||||
pack.title,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontSize: 20,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
// Подзаголовок (если есть)
|
||||
if (pack.subtitle != null &&
|
||||
pack.subtitle!.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
pack.subtitle!,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w300,
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Количество карточек
|
||||
Row(
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Заголовок
|
||||
Text(
|
||||
pack.cards.toString(),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w200,
|
||||
fontSize: 14,
|
||||
pack.title,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontSize: 20,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.style,
|
||||
size: 16,
|
||||
color: theme.colorScheme.primary,
|
||||
|
||||
// Подзаголовок (если есть)
|
||||
if (pack.subtitle != null &&
|
||||
pack.subtitle!.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
pack.subtitle!,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w300,
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Количество карточек
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
pack.cards.toString(),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w200,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.style,
|
||||
size: 16,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
// Pack tip overlay
|
||||
if (pack.tip != null) _buildPackTip(context, packColor),
|
||||
],
|
||||
),
|
||||
// Pack tip overlay
|
||||
if (pack.tip != null) _buildPackTip(context, packColor),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -117,30 +123,27 @@ class PackCardVertical extends StatelessWidget {
|
|||
}
|
||||
|
||||
Widget _buildPackImage(BuildContext context, Color packColor) {
|
||||
return Hero(
|
||||
tag: 'pack-${pack.id}',
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: packColor.withOpacity(0.1),
|
||||
),
|
||||
// Показываем изображение или иконку
|
||||
child: pack.imageBase64 != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(0.0),
|
||||
child: Image.memory(
|
||||
base64Decode(pack.imageBase64!),
|
||||
fit: BoxFit.contain
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
Icons.collections_bookmark,
|
||||
size: 64,
|
||||
color: packColor,
|
||||
),
|
||||
),
|
||||
return Container(
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: packColor.withOpacity(0.1),
|
||||
),
|
||||
// Показываем изображение или иконку
|
||||
child: pack.imageBase64 != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(0.0),
|
||||
child: Image.memory(
|
||||
base64Decode(pack.imageBase64!),
|
||||
fit: BoxFit.contain
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
Icons.collections_bookmark,
|
||||
size: 64,
|
||||
color: packColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -75,82 +75,87 @@ class PackDetailsHeader extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildTitleSection(BuildContext context, Color packColor) {
|
||||
// Try to get cached pack image
|
||||
final userScope = ScopeProvider.of<UserScope>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
|
||||
final cachedImage = userScope?.imageCacheService.getPackImage(pack.id);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Кнопка назад
|
||||
_buildBackButton(context, packColor),
|
||||
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Pack icon or image
|
||||
Hero(
|
||||
tag: 'pack-${pack.id}',
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: packColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
image: cachedImage != null
|
||||
? DecorationImage(
|
||||
image: cachedImage,
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: null,
|
||||
return Hero(
|
||||
tag: 'pack-hero-${pack.id}',
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: packColor.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
child: cachedImage == null
|
||||
? Icon(
|
||||
Icons.collections_bookmark,
|
||||
color: packColor,
|
||||
size: 28,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Заголовок и подзаголовок
|
||||
Expanded(
|
||||
child: Column(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
pack.title,
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
if (pack.subtitle.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
pack.subtitle,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
||||
height: 1.2,
|
||||
),
|
||||
// Кнопка назад
|
||||
_buildBackButton(context, packColor),
|
||||
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Pack icon or image
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: packColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
image: cachedImage != null
|
||||
? DecorationImage(
|
||||
image: cachedImage,
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
child: cachedImage == null
|
||||
? Icon(
|
||||
Icons.collections_bookmark,
|
||||
color: packColor,
|
||||
size: 28,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Заголовок и подзаголовок
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
pack.title,
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
if (pack.subtitle.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
pack.subtitle,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,18 @@ class StudySessionResponse {
|
|||
});
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('StatisticsStateManager', () {
|
||||
late StatisticsStateManager stateManager;
|
||||
late MockStatisticsService mockStatisticsService;
|
||||
|
||||
setUp(() {
|
||||
mockStatisticsService = MockStatisticsService();
|
||||
stateManager = StatisticsStateManager(
|
||||
statisticsService: mockStatisticsService,
|
||||
);
|
||||
});
|
||||
|
||||
// Note: These are smoke tests that verify method signatures and basic structure
|
||||
// Full integration tests would require HTTP mocking setup
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,207 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:google_sign_in/google_sign_in.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
import 'package:mnemo_cards_web_v2/domain/services/auth_service.dart';
|
||||
import 'package:mnemo_cards_web_v2/domain/services/http_repository_v2.dart';
|
||||
import 'package:mnemo_cards_web_v2/domain/services/pack_progress_service.dart';
|
||||
import 'package:mnemo_cards_web_v2/domain/state/user_state_manager.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
/// Mock classes
|
||||
class MockGoogleSignIn extends Mock implements GoogleSignIn {}
|
||||
|
||||
class MockHttpRepositoryV2 extends Mock implements HttpRepositoryV2 {}
|
||||
|
||||
/// Integration test for authentication and pack progress flow
|
||||
///
|
||||
/// This test verifies the full user journey:
|
||||
/// 1. User authenticates (auto-login or manual login)
|
||||
/// 2. User state is properly managed
|
||||
/// 3. User learns cards and progress is tracked
|
||||
/// 4. Progress is persisted locally
|
||||
/// 5. User logs out and data is cleared appropriately
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('Auth and Progress Integration Tests', () {
|
||||
late MockGoogleSignIn mockGoogleSignIn;
|
||||
late MockHttpRepositoryV2 mockHttpRepository;
|
||||
late AuthService authService;
|
||||
late PackProgressService packProgressService;
|
||||
late UserStateManager userStateManager;
|
||||
late SharedPreferences sharedPreferences;
|
||||
|
||||
const testUser = UserDto(
|
||||
id: 'test-user-123',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
);
|
||||
|
||||
const testPackId = 'pack-abc-123';
|
||||
const testCardId1 = 1;
|
||||
const testCardId2 = 2;
|
||||
|
||||
setUp(() async {
|
||||
// Initialize SharedPreferences with mock
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
sharedPreferences = await SharedPreferences.getInstance();
|
||||
|
||||
// Create mocks
|
||||
mockGoogleSignIn = MockGoogleSignIn();
|
||||
mockHttpRepository = MockHttpRepositoryV2();
|
||||
|
||||
// Create services
|
||||
authService = AuthService(
|
||||
googleSignIn: mockGoogleSignIn,
|
||||
httpRepository: mockHttpRepository,
|
||||
);
|
||||
|
||||
packProgressService = PackProgressService(
|
||||
sharedPreferences: sharedPreferences,
|
||||
httpRepository: mockHttpRepository,
|
||||
);
|
||||
|
||||
userStateManager = UserStateManager();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await sharedPreferences.clear();
|
||||
});
|
||||
|
||||
test('Complete user flow: login -> learn cards -> logout', () async {
|
||||
// === STEP 1: User is initially guest ===
|
||||
expect(userStateManager.state, equals(const UserState.guest()));
|
||||
|
||||
// === STEP 2: Auto-login attempt (no saved token) ===
|
||||
when(mockHttpRepository.isAuthenticated())
|
||||
.thenAnswer((_) async => false);
|
||||
|
||||
final autoLoginUser = await authService.autoLogin();
|
||||
expect(autoLoginUser, isNull);
|
||||
|
||||
// === STEP 3: Manual login with Telegram ===
|
||||
const telegramCode = 'telegram-auth-code-123';
|
||||
final authResponse = AuthResponse(
|
||||
user: testUser,
|
||||
accessToken: 'test-access-token',
|
||||
refreshToken: 'test-refresh-token',
|
||||
);
|
||||
|
||||
when(mockHttpRepository.authenticateWithTelegram(telegramCode))
|
||||
.thenAnswer((_) async => authResponse);
|
||||
|
||||
final loggedInUser = await authService.loginWithTelegram(telegramCode);
|
||||
expect(loggedInUser, isNotNull);
|
||||
expect(loggedInUser?.id, equals(testUser.id));
|
||||
expect(loggedInUser?.name, equals(testUser.name));
|
||||
|
||||
// Update user state
|
||||
userStateManager.setUser(loggedInUser!);
|
||||
expect(userStateManager.state, equals(UserState.authenticated(testUser)));
|
||||
|
||||
// === STEP 4: User starts learning cards ===
|
||||
// Check initial progress
|
||||
expect(packProgressService.getPackProgress(testPackId), equals(0));
|
||||
expect(packProgressService.isCardLearned(testPackId, testCardId1), isFalse);
|
||||
|
||||
// Mark first card as learned
|
||||
await packProgressService.markCardLearned(testPackId, testCardId1);
|
||||
|
||||
expect(packProgressService.getPackProgress(testPackId), equals(1));
|
||||
expect(packProgressService.isCardLearned(testPackId, testCardId1), isTrue);
|
||||
expect(packProgressService.isCardLearned(testPackId, testCardId2), isFalse);
|
||||
|
||||
// Mark second card as learned
|
||||
await packProgressService.markCardLearned(testPackId, testCardId2);
|
||||
|
||||
expect(packProgressService.getPackProgress(testPackId), equals(2));
|
||||
expect(packProgressService.isCardLearned(testPackId, testCardId2), isTrue);
|
||||
|
||||
// Get all learned cards
|
||||
final learnedCards = packProgressService.getLearnedCards(testPackId);
|
||||
expect(learnedCards, contains(testCardId1));
|
||||
expect(learnedCards, contains(testCardId2));
|
||||
expect(learnedCards.length, equals(2));
|
||||
|
||||
// === STEP 5: Progress persists across service recreations ===
|
||||
// Simulate app restart by creating new service instance
|
||||
final newPackProgressService = PackProgressService(
|
||||
sharedPreferences: sharedPreferences,
|
||||
httpRepository: mockHttpRepository,
|
||||
);
|
||||
|
||||
expect(newPackProgressService.getPackProgress(testPackId), equals(2));
|
||||
expect(newPackProgressService.isCardLearned(testPackId, testCardId1), isTrue);
|
||||
expect(newPackProgressService.isCardLearned(testPackId, testCardId2), isTrue);
|
||||
|
||||
// === STEP 6: User logs out ===
|
||||
when(mockHttpRepository.logout()).thenAnswer((_) async => {});
|
||||
when(mockHttpRepository.clearTokens()).thenAnswer((_) async => {});
|
||||
|
||||
await authService.logout();
|
||||
userStateManager.logout();
|
||||
|
||||
expect(userStateManager.state, equals(const UserState.guest()));
|
||||
|
||||
// Progress data should still be preserved locally
|
||||
// (in real app, you might want to clear it on logout)
|
||||
expect(newPackProgressService.getPackProgress(testPackId), equals(2));
|
||||
});
|
||||
|
||||
test('Duplicate card learning does not increase progress', () async {
|
||||
// Mark card as learned
|
||||
await packProgressService.markCardLearned(testPackId, testCardId1);
|
||||
expect(packProgressService.getPackProgress(testPackId), equals(1));
|
||||
|
||||
// Mark the same card again
|
||||
await packProgressService.markCardLearned(testPackId, testCardId1);
|
||||
expect(packProgressService.getPackProgress(testPackId), equals(1)); // Still 1
|
||||
|
||||
// Learned cards set should contain only one card
|
||||
final learnedCards = packProgressService.getLearnedCards(testPackId);
|
||||
expect(learnedCards.length, equals(1));
|
||||
});
|
||||
|
||||
test('Multiple packs have independent progress tracking', () async {
|
||||
const pack1 = 'pack-1';
|
||||
const pack2 = 'pack-2';
|
||||
|
||||
// Learn cards in pack 1
|
||||
await packProgressService.markCardLearned(pack1, testCardId1);
|
||||
await packProgressService.markCardLearned(pack1, testCardId2);
|
||||
|
||||
// Learn cards in pack 2
|
||||
await packProgressService.markCardLearned(pack2, testCardId1);
|
||||
|
||||
// Verify independent progress
|
||||
expect(packProgressService.getPackProgress(pack1), equals(2));
|
||||
expect(packProgressService.getPackProgress(pack2), equals(1));
|
||||
|
||||
// Verify card status is pack-specific
|
||||
expect(packProgressService.isCardLearned(pack1, testCardId1), isTrue);
|
||||
expect(packProgressService.isCardLearned(pack2, testCardId1), isTrue);
|
||||
expect(packProgressService.isCardLearned(pack1, testCardId2), isTrue);
|
||||
expect(packProgressService.isCardLearned(pack2, testCardId2), isFalse);
|
||||
});
|
||||
|
||||
test('User state transitions are correct', () async {
|
||||
// Start as guest
|
||||
expect(userStateManager.state.isGuest, isTrue);
|
||||
expect(userStateManager.state.isAuthenticated, isFalse);
|
||||
|
||||
// Login
|
||||
userStateManager.setUser(testUser);
|
||||
expect(userStateManager.state.isGuest, isFalse);
|
||||
expect(userStateManager.state.isAuthenticated, isTrue);
|
||||
expect(userStateManager.state.user, equals(testUser));
|
||||
|
||||
// Logout
|
||||
userStateManager.logout();
|
||||
expect(userStateManager.state.isGuest, isTrue);
|
||||
expect(userStateManager.state.isAuthenticated, isFalse);
|
||||
expect(userStateManager.state.user, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,274 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
import 'package:mnemo_cards_web_v2/domain/services/http_repository_v2.dart';
|
||||
import 'package:mnemo_cards_web_v2/domain/services/pack_progress_service.dart';
|
||||
import 'package:mnemo_cards_web_v2/domain/state/user_state_manager.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
/// Mock classes
|
||||
class MockHttpRepositoryV2 extends Mock implements HttpRepositoryV2 {}
|
||||
|
||||
/// Integration test for pack progress tracking and user state management
|
||||
///
|
||||
/// This test verifies:
|
||||
/// 1. Pack progress can be tracked and persisted locally
|
||||
/// 2. User state transitions work correctly
|
||||
/// 3. Multiple packs have independent progress
|
||||
/// 4. Data persists across service recreations (simulating app restarts)
|
||||
///
|
||||
/// This is a good example of an integration test because it tests
|
||||
/// how multiple components work together (PackProgressService, UserStateManager,
|
||||
/// SharedPreferences) rather than testing them in isolation.
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('Pack Progress and User State Integration Tests', () {
|
||||
late MockHttpRepositoryV2 mockHttpRepository;
|
||||
late PackProgressService packProgressService;
|
||||
late UserStateManager userStateManager;
|
||||
late SharedPreferences sharedPreferences;
|
||||
|
||||
final testUser = UserDto(
|
||||
id: 123,
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
);
|
||||
|
||||
const testPackId = 'pack-abc-123';
|
||||
const testCardId1 = 1;
|
||||
const testCardId2 = 2;
|
||||
const testCardId3 = 3;
|
||||
|
||||
setUp(() async {
|
||||
// Initialize SharedPreferences with mock
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
sharedPreferences = await SharedPreferences.getInstance();
|
||||
|
||||
// Create mocks
|
||||
mockHttpRepository = MockHttpRepositoryV2();
|
||||
|
||||
// Create services
|
||||
packProgressService = PackProgressService(
|
||||
sharedPreferences: sharedPreferences,
|
||||
httpRepository: mockHttpRepository,
|
||||
);
|
||||
|
||||
userStateManager = UserStateManager();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await sharedPreferences.clear();
|
||||
});
|
||||
|
||||
test('Complete user flow: user state + learning progress', () async {
|
||||
// === STEP 1: User starts as guest ===
|
||||
expect(userStateManager.state, equals(const UserState.guest()));
|
||||
expect(userStateManager.isGuest, isTrue);
|
||||
expect(userStateManager.isAuthenticated, isFalse);
|
||||
|
||||
// === STEP 2: Guest user can still track progress locally ===
|
||||
expect(packProgressService.getPackProgress(testPackId), equals(0));
|
||||
|
||||
await packProgressService.markCardLearned(testPackId, testCardId1);
|
||||
expect(packProgressService.getPackProgress(testPackId), equals(1));
|
||||
expect(packProgressService.isCardLearned(testPackId, testCardId1), isTrue);
|
||||
|
||||
// === STEP 3: User authenticates ===
|
||||
userStateManager.setUser(testUser);
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
expect(userStateManager.isAuthenticated, isTrue);
|
||||
expect(userStateManager.user?.id, equals(testUser.id));
|
||||
|
||||
// Progress should still be there after authentication
|
||||
expect(packProgressService.getPackProgress(testPackId), equals(1));
|
||||
|
||||
// === STEP 4: Authenticated user continues learning ===
|
||||
await packProgressService.markCardLearned(testPackId, testCardId2);
|
||||
await packProgressService.markCardLearned(testPackId, testCardId3);
|
||||
|
||||
expect(packProgressService.getPackProgress(testPackId), equals(3));
|
||||
|
||||
final learnedCards = packProgressService.getLearnedCards(testPackId);
|
||||
expect(learnedCards, containsAll([testCardId1, testCardId2, testCardId3]));
|
||||
expect(learnedCards.length, equals(3));
|
||||
|
||||
// === STEP 5: Simulate app restart (recreate services) ===
|
||||
final newPackProgressService = PackProgressService(
|
||||
sharedPreferences: sharedPreferences,
|
||||
httpRepository: mockHttpRepository,
|
||||
);
|
||||
|
||||
final newUserStateManager = UserStateManager();
|
||||
|
||||
// Progress should persist
|
||||
expect(newPackProgressService.getPackProgress(testPackId), equals(3));
|
||||
expect(newPackProgressService.isCardLearned(testPackId, testCardId1), isTrue);
|
||||
expect(newPackProgressService.isCardLearned(testPackId, testCardId2), isTrue);
|
||||
expect(newPackProgressService.isCardLearned(testPackId, testCardId3), isTrue);
|
||||
|
||||
// User state resets to guest (in real app, would be restored via auto-login)
|
||||
expect(newUserStateManager.isGuest, isTrue);
|
||||
|
||||
// ===STEP 6: User logs out ===
|
||||
userStateManager.logout();
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
expect(userStateManager.isGuest, isTrue);
|
||||
expect(userStateManager.user, isNull);
|
||||
|
||||
// Progress data is still preserved locally
|
||||
expect(packProgressService.getPackProgress(testPackId), equals(3));
|
||||
});
|
||||
|
||||
test('Duplicate card learning does not increase progress', () async {
|
||||
// Mark card as learned
|
||||
await packProgressService.markCardLearned(testPackId, testCardId1);
|
||||
expect(packProgressService.getPackProgress(testPackId), equals(1));
|
||||
|
||||
// Mark the same card again (should be idempotent)
|
||||
await packProgressService.markCardLearned(testPackId, testCardId1);
|
||||
expect(packProgressService.getPackProgress(testPackId), equals(1));
|
||||
|
||||
// Verify only one card is in learned set
|
||||
final learnedCards = packProgressService.getLearnedCards(testPackId);
|
||||
expect(learnedCards.length, equals(1));
|
||||
expect(learnedCards, contains(testCardId1));
|
||||
});
|
||||
|
||||
test('Multiple packs have independent progress tracking', () async {
|
||||
const pack1 = 'pack-1';
|
||||
const pack2 = 'pack-2';
|
||||
const pack3 = 'pack-3';
|
||||
|
||||
// Learn different cards in different packs
|
||||
await packProgressService.markCardLearned(pack1, testCardId1);
|
||||
await packProgressService.markCardLearned(pack1, testCardId2);
|
||||
|
||||
await packProgressService.markCardLearned(pack2, testCardId1);
|
||||
|
||||
await packProgressService.markCardLearned(pack3, testCardId1);
|
||||
await packProgressService.markCardLearned(pack3, testCardId2);
|
||||
await packProgressService.markCardLearned(pack3, testCardId3);
|
||||
|
||||
// Verify independent progress
|
||||
expect(packProgressService.getPackProgress(pack1), equals(2));
|
||||
expect(packProgressService.getPackProgress(pack2), equals(1));
|
||||
expect(packProgressService.getPackProgress(pack3), equals(3));
|
||||
|
||||
// Verify card status is pack-specific
|
||||
expect(packProgressService.isCardLearned(pack1, testCardId1), isTrue);
|
||||
expect(packProgressService.isCardLearned(pack1, testCardId3), isFalse);
|
||||
|
||||
expect(packProgressService.isCardLearned(pack2, testCardId1), isTrue);
|
||||
expect(packProgressService.isCardLearned(pack2, testCardId2), isFalse);
|
||||
|
||||
expect(packProgressService.isCardLearned(pack3, testCardId1), isTrue);
|
||||
expect(packProgressService.isCardLearned(pack3, testCardId2), isTrue);
|
||||
expect(packProgressService.isCardLearned(pack3, testCardId3), isTrue);
|
||||
});
|
||||
|
||||
test('User state transitions work correctly', () async {
|
||||
// Initial state: guest
|
||||
expect(userStateManager.state, equals(const UserState.guest()));
|
||||
expect(userStateManager.isGuest, isTrue);
|
||||
expect(userStateManager.isAuthenticated, isFalse);
|
||||
expect(userStateManager.user, isNull);
|
||||
|
||||
// Transition to authenticated
|
||||
userStateManager.setUser(testUser);
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
expect(userStateManager.isGuest, isFalse);
|
||||
expect(userStateManager.isAuthenticated, isTrue);
|
||||
expect(userStateManager.user, equals(testUser));
|
||||
expect(userStateManager.user?.name, equals('Test User'));
|
||||
|
||||
// Logout back to guest
|
||||
userStateManager.logout();
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
expect(userStateManager.isGuest, isTrue);
|
||||
expect(userStateManager.isAuthenticated, isFalse);
|
||||
expect(userStateManager.user, isNull);
|
||||
});
|
||||
|
||||
test('Pack progress increments correctly', () async {
|
||||
const packId = 'test-pack';
|
||||
|
||||
// Initial progress is 0
|
||||
expect(packProgressService.getPackProgress(packId), equals(0));
|
||||
|
||||
// Learn cards one by one
|
||||
await packProgressService.markCardLearned(packId, 1);
|
||||
expect(packProgressService.getPackProgress(packId), equals(1));
|
||||
|
||||
await packProgressService.markCardLearned(packId, 2);
|
||||
expect(packProgressService.getPackProgress(packId), equals(2));
|
||||
|
||||
await packProgressService.markCardLearned(packId, 3);
|
||||
expect(packProgressService.getPackProgress(packId), equals(3));
|
||||
|
||||
await packProgressService.markCardLearned(packId, 4);
|
||||
expect(packProgressService.getPackProgress(packId), equals(4));
|
||||
|
||||
await packProgressService.markCardLearned(packId, 5);
|
||||
expect(packProgressService.getPackProgress(packId), equals(5));
|
||||
|
||||
// Verify all cards are learned
|
||||
final learnedCards = packProgressService.getLearnedCards(packId);
|
||||
expect(learnedCards.length, equals(5));
|
||||
for (int i = 1; i <= 5; i++) {
|
||||
expect(packProgressService.isCardLearned(packId, i), isTrue);
|
||||
}
|
||||
});
|
||||
|
||||
test('Progress persists after SharedPreferences reload', () async {
|
||||
// Learn some cards
|
||||
await packProgressService.markCardLearned(testPackId, testCardId1);
|
||||
await packProgressService.markCardLearned(testPackId, testCardId2);
|
||||
|
||||
expect(packProgressService.getPackProgress(testPackId), equals(2));
|
||||
|
||||
// Get the same SharedPreferences instance (simulating persistence)
|
||||
final samePrefs = await SharedPreferences.getInstance();
|
||||
|
||||
// Create new service with same SharedPreferences
|
||||
final newService = PackProgressService(
|
||||
sharedPreferences: samePrefs,
|
||||
httpRepository: mockHttpRepository,
|
||||
);
|
||||
|
||||
// Progress should be restored
|
||||
expect(newService.getPackProgress(testPackId), equals(2));
|
||||
expect(newService.isCardLearned(testPackId, testCardId1), isTrue);
|
||||
expect(newService.isCardLearned(testPackId, testCardId2), isTrue);
|
||||
});
|
||||
|
||||
test('User can learn and unlearn workflow', () async {
|
||||
// Set authenticated user
|
||||
userStateManager.setUser(testUser);
|
||||
|
||||
// Learn cards
|
||||
await packProgressService.markCardLearned(testPackId, testCardId1);
|
||||
await packProgressService.markCardLearned(testPackId, testCardId2);
|
||||
|
||||
expect(packProgressService.getPackProgress(testPackId), equals(2));
|
||||
|
||||
// In a real app, you might have an "unlearn" or "reset progress" feature
|
||||
// For now, we can verify the current state is correct
|
||||
expect(packProgressService.getLearnedCards(testPackId).length, equals(2));
|
||||
|
||||
// User logs out
|
||||
userStateManager.logout();
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
expect(userStateManager.isGuest, isTrue);
|
||||
|
||||
// Progress remains (local data not cleared on logout)
|
||||
expect(packProgressService.getPackProgress(testPackId), equals(2));
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue