tests and stats

This commit is contained in:
Dmitry 2024-06-13 02:00:13 +03:00
parent a6d5583c3f
commit 69930bd9df
22 changed files with 425 additions and 547 deletions

View file

@ -14,6 +14,7 @@ import 'package:mnemo_cards/widgets/header.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import '../main.dart'; import '../main.dart';
import '../widgets/image_fade.dart'; import '../widgets/image_fade.dart';
@ -256,7 +257,10 @@ class AddPackage with Api {
'Preview cards', 'Preview cards',
cardPackDto.previewCards?.toString(), cardPackDto.previewCards?.toString(),
(v) { (v) {
final cards = (jsonDecode(v) as List).cast<String>(); final cards = v
.decodeList<int>()
.map((e) => e.toString())
.toList();
cardPackDto = cardPackDto.copyWith.previewCards(cards); cardPackDto = cardPackDto.copyWith.previewCards(cards);
}, },
), ),

View file

@ -1,5 +1,4 @@
import 'dart:async'; import 'dart:async';
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
@ -8,6 +7,7 @@ import 'package:mnemo_cards/features/packs/packs_api.dart';
import 'package:mnemo_cards/managers/repository/api.dart'; import 'package:mnemo_cards/managers/repository/api.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
class AdminApi with Api { class AdminApi with Api {
final Dio _dio; final Dio _dio;
@ -41,10 +41,7 @@ class AdminApi with Api {
'$path/cards', '$path/cards',
queryParameters: {'ids': ids.join(',')}, queryParameters: {'ids': ids.join(',')},
); );
return (jsonDecode(r.data!) as List) return (r.data as String).decodeList(GameCardDto.fromJson);
.map((e) => GameCardDto.fromJson(e as Map<String, dynamic>))
.toList()
.cast();
} }
Future<List<UserDto>> getUsers(List<String> ids) async { Future<List<UserDto>> getUsers(List<String> ids) async {
@ -94,9 +91,7 @@ class AdminApi with Api {
final r = await _dio.get<String>( final r = await _dio.get<String>(
'$path/pack/edit/$id', '$path/pack/edit/$id',
); );
return EditCardPackDto.fromJson( return (r.data as String).decode(EditCardPackDto.fromJson);
jsonDecode(r.data as String) as Map<String, Object?>,
);
} }
Future<bool> deletePack(EditCardPackDto dto) async { Future<bool> deletePack(EditCardPackDto dto) async {

View file

@ -6,6 +6,7 @@ import 'dart:typed_data';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@ -27,11 +28,12 @@ class PackCacheManager {
final dir = await _packDirectory(packId); final dir = await _packDirectory(packId);
final file = File('${dir.path}/pack.json'); final file = File('${dir.path}/pack.json');
if (file.existsSync()) { if (file.existsSync()) {
final json = jsonEncode(await file.readAsString()) as Map; final json = (await file.readAsString()).decode<Map>();
json['id'] = '0'; json['id'] = '0';
await file.writeAsString(jsonEncode(json)); await file.writeAsString(jsonEncode(json));
} }
} catch (e, s) { } catch (e, s) {
log('Cant marck pack for update', error: e, stackTrace: s);
Analytics.crashlyticsError( Analytics.crashlyticsError(
e: e, e: e,
s: s, s: s,
@ -52,9 +54,7 @@ class PackCacheManager {
log('Pack file not exist'); log('Pack file not exist');
return throw Exception('Pack file not exist'); return throw Exception('Pack file not exist');
} }
var pack = CardPackDto.fromJson( var pack = packFile.readAsStringSync().decode(CardPackDto.fromJson);
jsonDecode(packFile.readAsStringSync()),
);
if (withCardImages) { if (withCardImages) {
Map<String, MemoryImage> images = {}; Map<String, MemoryImage> images = {};
log('Cards ${packId}: ${pack.cards.length}'); log('Cards ${packId}: ${pack.cards.length}');
@ -147,7 +147,7 @@ class PackCacheManager {
}) async { }) async {
log('Saving pack ${dto.id}'); log('Saving pack ${dto.id}');
final dir = await _packDirectory(dto.id); final dir = await _packDirectory(dto.id);
final json = jsonEncode(dto); final json = dto.encode();
log('Saving dto ${dto.id}'); log('Saving dto ${dto.id}');
File('${dir.path}/pack.json') File('${dir.path}/pack.json')
..createSync(recursive: true) ..createSync(recursive: true)

View file

@ -1,4 +1,3 @@
import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
@ -19,10 +18,8 @@ class PacksApi with Api {
'$path/packs/previews', '$path/packs/previews',
queryParameters: packData, queryParameters: packData,
); );
final previews = (jsonDecode(r.data!) as List<dynamic>) final previews =
.cast<Map<String, dynamic>>() (r.data! as String).decodeList(CardPackPreviewDto.fromJson).toList();
.map(CardPackPreviewDto.fromJson)
.toList();
return previews; return previews;
} }
@ -34,10 +31,7 @@ class PacksApi with Api {
'$path/packs/actions', '$path/packs/actions',
queryParameters: packData, queryParameters: packData,
); );
final actions = (jsonDecode(r.data!) as List<dynamic>) final actions = (r.data! as String).decodeList(CardPackAction.fromJson);
.cast<Map<String, Object?>>()
.map(CardPackAction.fromJson)
.toList();
return actions; return actions;
} }
@ -50,9 +44,7 @@ class PacksApi with Api {
receiveTimeout: Duration(seconds: 5), receiveTimeout: Duration(seconds: 5),
), ),
); );
return CardPackDto.fromJson( return (r.data as String).decode(CardPackDto.fromJson);
jsonDecode(r.data as String) as Map<String, Object?>,
);
} }
// buy page dto if not available // buy page dto if not available
@ -64,9 +56,7 @@ class PacksApi with Api {
receiveTimeout: Duration(seconds: 5), receiveTimeout: Duration(seconds: 5),
), ),
); );
return CardPackBuyDto.fromJson( return (r.data as String).decode(CardPackBuyDto.fromJson);
jsonDecode(r.data as String) as Map<String, Object?>,
);
} }
/// cards zip archive for available pack /// cards zip archive for available pack

View file

@ -3,6 +3,7 @@ import 'dart:convert';
import 'dart:developer'; import 'dart:developer';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:rxdart/rxdart.dart'; import 'package:rxdart/rxdart.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@ -30,9 +31,7 @@ class PreviewPackHolder {
final List<CardPackPreviewDto> previewPacks = []; final List<CardPackPreviewDto> previewPacks = [];
previewPacksStrings.forEach((string) { previewPacksStrings.forEach((string) {
try { try {
final pack = CardPackPreviewDto.fromJson( final pack = string.decode(CardPackPreviewDto.fromJson);
jsonDecode(string) as Map<String, Object?>,
);
previewPacks.add(pack); previewPacks.add(pack);
} catch (e, s) { } catch (e, s) {
log('Cant load preview pack', error: e, stackTrace: s); log('Cant load preview pack', error: e, stackTrace: s);

View file

@ -2,9 +2,11 @@ import 'dart:math';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:mnemo_cards/flags.dart';
import 'package:rxdart/rxdart.dart'; import 'package:rxdart/rxdart.dart';
import '../../theme/themes.dart'; import '../../theme/themes.dart';
@ -122,6 +124,10 @@ class _ProgressWidgetState extends State<ProgressWidget> {
), ),
], ],
), ),
if (kDebugMode)
Text(
'${(results.where((r) => r != Result.not_visited).length)}/${results.length}',
)
], ],
), ),
), ),

View file

@ -25,6 +25,6 @@ class InputButtonsTestState extends TestQuestionState {
this.isCorrect = false, this.isCorrect = false,
this.isAnswered = false, this.isAnswered = false,
this.isSkipped = false, this.isSkipped = false,
super.testType = TestQuestionType.simple, super.testType = TestQuestionType.input_buttons,
}); });
} }

View file

@ -6,6 +6,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:mnemo_cards/utils/color_helper.dart'; import 'package:mnemo_cards/utils/color_helper.dart';
import 'package:rxdart/rxdart.dart'; import 'package:rxdart/rxdart.dart';
import '../../di/locator.dart';
import '../../widgets/big_back_button.dart'; import '../../widgets/big_back_button.dart';
import '../../widgets/horizontal_progress.dart'; import '../../widgets/horizontal_progress.dart';
import 'question_states/test_question_state.dart'; import 'question_states/test_question_state.dart';
@ -37,8 +38,18 @@ class TestCompleteWidget extends StatelessWidget {
) )
.values .values
.toList() .toList()
..sort((p, n) => p.correctCount.compareTo(n.correctCount)); ..sort(
return Column( (p, n) => (p.correctCount.toDouble() / p.totalCount)
.compareTo(n.correctCount.toDouble() / n.totalCount),
);
return PopScope(
onPopInvoked: (v) {
if (v) {
locator.testManager.sendStatistics();
locator.testManager.triggerPoll();
}
},
child: Column(
children: [ children: [
Expanded( Expanded(
flex: 3, flex: 3,
@ -163,6 +174,7 @@ class TestCompleteWidget extends StatelessWidget {
), ),
const BigBackButton(), const BigBackButton(),
], ],
),
); );
} }
} }

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:mnemo_cards/features/packs/preview_packs_poller.dart';
import 'package:mnemo_cards/features/tests/test_state_holder.dart'; import 'package:mnemo_cards/features/tests/test_state_holder.dart';
import 'package:mnemo_cards/features/tests/test_widgets/test_image.dart'; import 'package:mnemo_cards/features/tests/test_widgets/test_image.dart';
import 'package:mnemo_cards/features/packs/pack_cache_manager.dart'; import 'package:mnemo_cards/features/packs/pack_cache_manager.dart';
@ -18,15 +19,22 @@ import 'question_states/test_question_state.dart';
class TestManager extends ChangeNotifier { class TestManager extends ChangeNotifier {
final PackCacheManager packCacheManager; final PackCacheManager packCacheManager;
final HttpRepository _repository; final HttpRepository _repository;
final List<AbstractTestQuestion> questions = [];
Color color = borderGray;
final PageController pageController = PageController(); final PageController pageController = PageController();
final TestHolder testHolder = TestHolder(); final TestHolder testHolder = TestHolder();
final StreamController<void> _triggerController =
StreamController.broadcast();
Future<List<TestDto>> loadTests({String? packId}) async { TestDto? _test;
final tests = (await _repository.getTests(packId: packId ?? ''))
.map((e) => TestDto.fromJson(e)) Color get color => _test?.color?.asColor ?? borderGray;
.toList();
List<AbstractTestQuestion> get questions => _test?.questions ?? [];
void triggerPoll() => _triggerController.add(1);
Future<List<TestDto>> pollTests({String? packId}) async {
print('polling tests');
final tests = (await _repository.getTests(packId: packId ?? ''));
return tests; return tests;
} }
@ -36,12 +44,16 @@ class TestManager extends ChangeNotifier {
} }
Stream<List<TestDto>> loadTestsStream({String? packId}) { Stream<List<TestDto>> loadTestsStream({String? packId}) {
return Stream.periodic( return Rx.combineLatest2(
Duration(seconds: 60), _triggerController.stream.startWith(null),
Stream.periodic(
Duration(seconds: 120),
).startWith(null),
(_, __) => null,
) )
.startWith(null) .throttleTime(Duration(seconds: 1))
.asyncMap( .asyncMap(
(tests) async => loadTests(packId: packId) (tests) async => pollTests(packId: packId)
.catchError((_) => Future.value(<TestDto>[])), .catchError((_) => Future.value(<TestDto>[])),
) )
.where((test) => test.isNotEmpty); .where((test) => test.isNotEmpty);
@ -56,19 +68,20 @@ class TestManager extends ChangeNotifier {
if (packId != null) { if (packId != null) {
await packCacheManager.loadPackFromCache(packId); await packCacheManager.loadPackFromCache(packId);
} }
TestDto? test;
try { try {
test = await _repository.getTest(id); _test = await _repository.getTest(id);
} on Object catch (e, s) { } on Object catch (e, s) {
log('error when loading test ${id}'); log('error when loading test ${id}', error: e, stackTrace: s);
} }
if (test == null) { if (_test == null) {
appRouter.maybePop(); appRouter.maybePop();
return;
} }
questions.clear(); // todo use uuid
questions.addAll(test!.questions); testHolder.setQuestions(
color = test.color?.asColor ?? borderGray; _test!.questions,
testHolder.setQuestions(questions); DateTime.timestamp().toString(),
);
} }
void setState(int id, TestQuestionState state) { void setState(int id, TestQuestionState state) {
@ -153,4 +166,28 @@ class TestManager extends ChangeNotifier {
final bytes = await packCacheManager.loadPackFile(packId, path); final bytes = await packCacheManager.loadPackFile(packId, path);
return MemoryImage(bytes!); return MemoryImage(bytes!);
} }
Future<void> sendStatistics() {
final words = states
.fold(<String, WordStatisticsDto>{}, (stats, s) {
final existStat = stats[s.word] ?? WordStatisticsDto.empty(s.word);
stats[s.word] = existStat.copyWith(
correct: existStat.correct + (s.isCorrect ? 1 : 0),
incorrect:
existStat.incorrect + ((!s.isCorrect && s.isAnswered) ? 1 : 0),
skipped: existStat.skipped + (s.isAnswered ? 0 : 1),
questionTypes: {...existStat.questionTypes, s.testType},
);
return stats;
})
.values
.toList();
final dto = TestStatisticsDto(
testId: int.tryParse(_test?.id ?? '') ?? -1,
words: AllWordsStatisticsDto(words: words),
sessionToken: testHolder.state.sessionToken,
);
return _repository.addTestStatistics(dto);
}
} }

View file

@ -2,9 +2,12 @@ import 'dart:developer';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/foundation.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:mnemo_cards/managers/audio_player.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:rxdart/rxdart.dart';
import '../../di/locator.dart'; import '../../di/locator.dart';
import 'scroll_physics.dart'; import 'scroll_physics.dart';
@ -26,6 +29,9 @@ class TestPage extends StatefulWidget {
class _TestPageState extends State<TestPage> { class _TestPageState extends State<TestPage> {
PageController get pageController => locator.testManager.pageController; PageController get pageController => locator.testManager.pageController;
int? lastSkipIndex;
int? _lastAudioQuestionId;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -41,25 +47,46 @@ class _TestPageState extends State<TestPage> {
} }
void _pageScrollListener() { void _pageScrollListener() {
final index = pageController.page?.floor() ?? 0; if (!pageController.hasClients) {
return;
}
final index = pageController.page!.floor();
print('Current index $index, lastAudio $_lastAudioQuestionId');
if (index < locator.testManager.questions.length) {
final question = locator.testManager.questions[index];
if (question.id != _lastAudioQuestionId) {
print('AUDIO $_lastAudioQuestionId ${question.id}');
_lastAudioQuestionId = question.id;
try {
AudioPlayer.playAudio((question as dynamic).audio);
} catch (e, s) {}
}
}
if (index == lastSkipIndex) {
return;
}
print('Will try to skip $index, ${locator.testManager.questions.length}');
if (index > 0 && index <= locator.testManager.questions.length) { if (index > 0 && index <= locator.testManager.questions.length) {
final prevId = locator.testManager.questions[index - 1].id!; final prevId = locator.testManager.questions[index - 1].id!;
final state = locator.testManager.getState(prevId); final state = locator.testManager.getState(prevId);
if (state?.isAnswered == false) { if (state?.isAnswered == false) {
// log('Question skipped'); print('Skipping $index ${state?.word}');
locator.testManager.skip(prevId); locator.testManager.skip(prevId);
lastSkipIndex = index;
} }
} }
} }
int lastTestIndex = 0;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: Colors.white, backgroundColor: Colors.white,
body: StreamBuilder( body: StreamBuilder(
stream: locator.testManager.testHolder.asStream, stream: locator.testManager.testHolder.asStream.takeWhileInclusive(
(state) => state?.questions.isNotEmpty != true,
),
builder: (context, s) { builder: (context, s) {
if (!s.hasData || s.requireData!.questions.isEmpty) { if (!s.hasData || s.requireData!.questions.isEmpty) {
return Scaffold( return Scaffold(
@ -69,6 +96,16 @@ class _TestPageState extends State<TestPage> {
); );
} }
WidgetsBinding.instance.addPostFrameCallback((_) {
_pageScrollListener();
});
final keys = List.generate(
locator.testManager.questions.length,
(v) =>
GlobalKey(debugLabel: locator.testManager.questions[v].word),
);
return Scaffold( return Scaffold(
backgroundColor: locator.testManager.color.withOpacity(0.25), backgroundColor: locator.testManager.color.withOpacity(0.25),
body: SafeArea( body: SafeArea(
@ -100,27 +137,55 @@ class _TestPageState extends State<TestPage> {
locator.testManager.color, locator.testManager.color,
); );
} }
final question = locator.testManager.getTest(index);
lastTestIndex = math.max(index, lastTestIndex); return switch (question.questionType) {
return switch (locator.testManager
.getTest(index)
.questionType) {
TestQuestionType.simple => SimpleTestWidget( TestQuestionType.simple => SimpleTestWidget(
locator.testManager.getTest(index) question as SimpleTestQuestionBody,
as SimpleTestQuestionBody, key: keys[index],
), ),
TestQuestionType.input_buttons => TestQuestionType.input_buttons =>
InputButtonsTestWidget( InputButtonsTestWidget(
locator.testManager.getTest(index) question as InputButtonsTestQuestionBody,
as InputButtonsTestQuestionBody, key: keys[index]),
),
TestQuestionType.matrix => Container(), TestQuestionType.matrix => Container(),
TestQuestionType.match => Container(), TestQuestionType.match => Container(),
// TODO: Handle this case. // TODO: Handle this case.
TestQuestionType.undefined => TestQuestionType.undefined =>
throw UnimplementedError(), throw UnimplementedError(),
}; };
}.let(
(builder) => (c, i) =>
builder.call(c, i)?.let((w) => kDebugMode
? Stack(
children: [
w,
Row(
children: [
Text('$i'),
IconButton(
onPressed: () {
locator.testManager
.pageController
.animateToPage(
locator
.testManager
.pageController
.page!
.toInt() +
1,
duration: Duration(
milliseconds: 500),
curve: Curves.easeIn,
);
}, },
icon: Icon(
Icons.next_plan_outlined))
],
),
],
)
: w),
),
), ),
), ),
], ],

View file

@ -25,11 +25,12 @@ class _TestProgressWidgetState extends State<TestProgressWidget> {
Stream<Duration>? timer; Stream<Duration>? timer;
Timer? _updateTimer; bool _active = false;
void _updateDataListener() { void _updateDataListener() {
if (_updateTimer == null || !_updateTimer!.isActive) { if (!_active) {
_updateTimer = Timer(Duration(milliseconds: 100), _updateData); _active = true;
Timer(Duration(milliseconds: 100), () => _updateData());
} }
} }
@ -53,16 +54,16 @@ class _TestProgressWidgetState extends State<TestProgressWidget> {
results.add(Result.not_visited); results.add(Result.not_visited);
} }
} }
print('Update data ${results.map((e) => e.name).join(' ')}');
if (mounted) { if (mounted) {
setState(() {}); setState(() {});
} }
_active = false;
} }
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// locator.testManager.removeListener(_updateData);
// locator.testManager.addListener(_updateData);
locator.testManager.pageController.removeListener(_updateDataListener); locator.testManager.pageController.removeListener(_updateDataListener);
locator.testManager.pageController.addListener(_updateDataListener); locator.testManager.pageController.addListener(_updateDataListener);
timer = Stream.periodic( timer = Stream.periodic(
@ -79,7 +80,6 @@ class _TestProgressWidgetState extends State<TestProgressWidget> {
length, length,
locator.testManager.color, locator.testManager.color,
timer: timer, timer: timer,
key: ValueKey('TEST PROGRESS'),
); );
} }
} }

View file

@ -13,17 +13,20 @@ class TestHolder {
StreamController.broadcast(); StreamController.broadcast();
_TestState __state; _TestState __state;
TestHolder() : __state = _TestState({}); TestHolder() : __state = _TestState({}, null);
void skip(int id) { void skip(int id) {
final state = testQuestionState(id); final state = testQuestionState(id);
print('Skipping $id ${state} ${state?.testType}');
switch (state?.testType) { switch (state?.testType) {
case TestQuestionType.simple: case TestQuestionType.simple:
state as SimpleTestState; state as SimpleTestState;
print('Skipped $id ${state.word}');
setQuestionState(id, state.copyWith(isSkipped: true)); setQuestionState(id, state.copyWith(isSkipped: true));
break; break;
case TestQuestionType.input_buttons: case TestQuestionType.input_buttons:
state as InputButtonsTestState; state as InputButtonsTestState;
print('Skipped $id ${state.word}');
setQuestionState(id, state.copyWith(isSkipped: true)); setQuestionState(id, state.copyWith(isSkipped: true));
break; break;
case TestQuestionType.matrix: case TestQuestionType.matrix:
@ -40,16 +43,20 @@ class TestHolder {
} }
} }
void setQuestionState(int id, TestQuestionState state) => _state = _TestState( void setQuestionState(int id, TestQuestionState state) {
_state = _TestState(
Map.from(_state.questions..[id] = state), Map.from(_state.questions..[id] = state),
_state.sessionToken,
); );
}
void clear() { void clear() {
_state = _TestState({}); _state = _TestState({}, null);
_streamController.add(_state); _streamController.add(_state);
} }
void setQuestions(List<AbstractTestQuestion> questions) { void setQuestions(
List<AbstractTestQuestion> questions, String? sessionToken) {
final stateQuestions = <int, TestQuestionState>{}; final stateQuestions = <int, TestQuestionState>{};
final seed = Random().nextInt(999); final seed = Random().nextInt(999);
for (final question in questions) { for (final question in questions) {
@ -72,7 +79,7 @@ class TestHolder {
// TODO: Handle this case. // TODO: Handle this case.
} }
} }
_state = _TestState(stateQuestions); _state = _TestState(stateQuestions, sessionToken);
} }
TestQuestionState? testQuestionState(int id) => _state.questions[id]; TestQuestionState? testQuestionState(int id) => _state.questions[id];
@ -91,7 +98,8 @@ class TestHolder {
} }
class _TestState { class _TestState {
final String? sessionToken;
final Map<int, TestQuestionState> questions; final Map<int, TestQuestionState> questions;
_TestState(this.questions); _TestState(this.questions, this.sessionToken);
} }

View file

@ -1,6 +1,7 @@
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:flutter_tts/flutter_tts.dart'; import 'package:flutter_tts/flutter_tts.dart';
import 'package:mnemo_cards/managers/audio_player.dart';
import '../../../main.dart'; import '../../../main.dart';
import '../../../widgets/game_card_widget.dart'; import '../../../widgets/game_card_widget.dart';
@ -11,15 +12,17 @@ class CommonTestQuestionWidget extends StatelessWidget {
final String? image; final String? image;
final String? audio; final String? audio;
CommonTestQuestionWidget({ const CommonTestQuestionWidget({
this.text, this.text,
this.image, this.image,
this.audio, this.audio,
super.key,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context).textTheme; final theme = Theme.of(context).textTheme;
return Column( return Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
@ -49,7 +52,7 @@ class CommonTestQuestionWidget extends StatelessWidget {
'icons/sound_on.png', 'icons/sound_on.png',
width: 26, width: 26,
), ),
onPressed: () => _playAudio(), onPressed: () => AudioPlayer.playAudio(audio),
), ),
), ),
], ],
@ -66,7 +69,7 @@ class CommonTestQuestionWidget extends StatelessWidget {
width: 80, width: 80,
height: 80, height: 80,
), ),
onPressed: () => _playAudio(), onPressed: () => AudioPlayer.playAudio(audio),
), ),
), ),
), ),
@ -91,7 +94,7 @@ class CommonTestQuestionWidget extends StatelessWidget {
width: 26, width: 26,
height: theme.headlineLarge?.fontSize, height: theme.headlineLarge?.fontSize,
), ),
onPressed: () => _playAudio(), onPressed: () => AudioPlayer.playAudio(audio),
), ),
) )
], ],
@ -100,18 +103,4 @@ class CommonTestQuestionWidget extends StatelessWidget {
], ],
); );
} }
Future<void> _playAudio() async {
if (globalSharedPreferences.getBool('sound_on') != false && audio != null) {
FlutterTts flutterTts = FlutterTts();
var playText = audio!;
if (audio!.contains('_')) {
final lang = audio!.split('_').first;
playText = audio!.replaceFirst('${lang}_', '');
await flutterTts.setLanguage(lang);
}
await flutterTts.setSpeechRate(0.5);
flutterTts.speak(playText);
}
}
} }

View file

@ -27,12 +27,13 @@ class SimpleTestWidget extends StatelessWidget {
Color get testColor => manager.color; Color get testColor => manager.color;
SimpleTestWidget(this._simpleTestModel, {super.key}); const SimpleTestWidget(this._simpleTestModel, {super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final random = Random(state.seed); final random = Random(state.seed);
final buttons = [...model.buttons]..shuffle(random); final buttons = [...model.buttons]..shuffle(random);
return LayoutBuilder(builder: (context, constraints) { return LayoutBuilder(builder: (context, constraints) {
return Padding( return Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),

View file

@ -0,0 +1,19 @@
import 'package:flutter_tts/flutter_tts.dart';
import '../main.dart';
class AudioPlayer {
static Future<void> playAudio(String? audio) async {
if (globalSharedPreferences.getBool('sound_on') != false && audio != null) {
FlutterTts flutterTts = FlutterTts();
var playText = audio;
if (audio.contains('_')) {
final lang = audio.split('_').first;
playText = audio.replaceFirst('${lang}_', '');
await flutterTts.setLanguage(lang);
}
await flutterTts.setSpeechRate(0.5);
flutterTts.speak(playText);
}
}
}

View file

@ -1,39 +1,18 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:developer'; import 'dart:developer';
import 'dart:io'; import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:mnemo_cards/managers/repository/repository.dart'; import 'package:mnemo_cards/managers/repository/repository.dart';
import 'package:mnemo_cards/managers/user_manager.dart'; import 'package:mnemo_cards/managers/user_manager.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../../features/purchase/in_app_purchase.dart';
import '../../main.dart';
import 'api.dart'; import 'api.dart';
import 'firebase_config_repository.dart';
class HttpRepository extends Repository with Api { class HttpRepository extends Repository with Api {
final Dio _dio; final Dio _dio;
HttpRepository(this._dio); HttpRepository(this._dio);
@override
Future<List<Map<String, Object?>>> getPacks(
Map<String, String?>? packData) async {
final r = await _dio.get<String>(
'$path/packs/',
queryParameters: packData,
);
final ll =
(jsonDecode(r.data!) as List<dynamic>).cast<Map<String, Object?>>();
return ll;
}
@override @override
Future<UserDto> get user async => UserDto.empty; Future<UserDto> get user async => UserDto.empty;
@ -58,12 +37,7 @@ class HttpRepository extends Repository with Api {
}); });
//todo add client header encryption ?? //todo add client header encryption ??
final authToken = r.headers[HttpHeaders.authorizationHeader]!.last; final authToken = r.headers[HttpHeaders.authorizationHeader]!.last;
return ( return ((r.data as String).decode(UserDto.fromJson), authToken);
UserDto.fromJson(
jsonDecode(r.data as String) as Map<String, Object?>,
),
authToken,
);
} on DioException catch (e, s) { } on DioException catch (e, s) {
log('User create error', error: e, stackTrace: s); log('User create error', error: e, stackTrace: s);
rethrow; rethrow;
@ -83,24 +57,35 @@ class HttpRepository extends Repository with Api {
Future<UserDto?> getUser() async { Future<UserDto?> getUser() async {
final r = await _dio.get<String>('$path/user'); final r = await _dio.get<String>('$path/user');
if (r.statusCode == 200) { if (r.statusCode == 200) {
return UserDto.fromJson( return (r.data as String).decode(UserDto.fromJson);
jsonDecode(r.data as String) as Map<String, Object?>,
);
} }
return null; return null;
} }
@override @override
Future<void> updateUserData(String id, Map<String, Object?> data) async {} Future<void> updateUserData(UserDataDto data) async {
final r = await _dio.post('$path/user/data', data: data.encode());
}
@override @override
Future<List<Map<String, Object?>>> getTests({String packId = ''}) async { Future<void> updateUserSettings(UserSettingsDto settings) async {
final r = await _dio.post('$path/user/settings', data: settings.encode());
}
@override
Future<void> addTestStatistics(TestStatisticsDto testStatistics) async {
final r = await _dio.post(
'$path/user/data/add/test-statistics',
data: testStatistics.encode(),
);
}
@override
Future<List<TestDto>> getTests({String packId = ''}) async {
final r = await _dio.get<String>( final r = await _dio.get<String>(
'$path/tests/$packId', '$path/tests/$packId',
); );
final ll = return (r.data as String).decodeList(TestDto.fromJson);
(jsonDecode(r.data!) as List<dynamic>).cast<Map<String, Object?>>();
return ll;
} }
@override @override
@ -109,9 +94,7 @@ class HttpRepository extends Repository with Api {
'$path/test/$id', '$path/test/$id',
); );
try { try {
return TestDto.fromJson( return (r.data as String).decode(TestDto.fromJson);
jsonDecode(r.data as String) as Map<String, Object?>,
);
} catch (e, s) { } catch (e, s) {
log(e.toString(), stackTrace: s); log(e.toString(), stackTrace: s);
return null; return null;

View file

@ -18,9 +18,13 @@ abstract class Repository {
Future<UserDto?> getUser(); Future<UserDto?> getUser();
Future<void> updateUserData(String id, Map<String, Object?> data); Future<void> updateUserData(UserDataDto data);
Future<List<Map<String, Object?>>> getTests(); Future<void> updateUserSettings(UserSettingsDto settings);
Future<void> addTestStatistics(TestStatisticsDto testStatistics);
Future<List<TestDto>> getTests();
Future<TestDto?> getTest(String id); Future<TestDto?> getTest(String id);

View file

@ -13,6 +13,7 @@ import '../../features/yandex_ads/yandex_ads.dart';
import '../../di/locator.dart'; import '../../di/locator.dart';
import '../../theme/themes.dart'; import '../../theme/themes.dart';
import '../horizontal_progress.dart';
import 'card_pack_header.dart'; import 'card_pack_header.dart';
import 'package:mnemo_cards/utils/string_helper.dart'; import 'package:mnemo_cards/utils/string_helper.dart';
@ -180,6 +181,8 @@ class _TestsSection extends StatelessWidget {
child: _TestButton( child: _TestButton(
title: dto.name, title: dto.name,
subtitle: null, subtitle: null,
stats: dto.statistics,
color: dto.color?.asColor ?? borderGray,
time: dto.time, time: dto.time,
timeSubtitle: dto.timeSubtitle, timeSubtitle: dto.timeSubtitle,
onTap: () { onTap: () {
@ -199,11 +202,10 @@ class _TestsSection extends StatelessWidget {
} }
class _AdTile extends StatelessWidget { class _AdTile extends StatelessWidget {
const _AdTile({super.key}); const _AdTile();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context).textTheme;
return LayoutBuilder(builder: (context, constraints) { return LayoutBuilder(builder: (context, constraints) {
final banner = YandexAds.createBanner( final banner = YandexAds.createBanner(
BoxConstraints.tight(Size(constraints.maxWidth - 16, 80 - 8))); BoxConstraints.tight(Size(constraints.maxWidth - 16, 80 - 8)));
@ -265,13 +267,18 @@ class _TestButton extends StatelessWidget {
final String? subtitle; final String? subtitle;
final String? time; final String? time;
final String? timeSubtitle; final String? timeSubtitle;
final Color color;
final TestStatisticsDto? stats;
_TestButton({ _TestButton({
required this.title, required this.title,
required this.onTap, required this.onTap,
required this.color,
this.subtitle, this.subtitle,
this.time, this.time,
this.timeSubtitle, this.timeSubtitle,
this.stats,
super.key,
}); });
@override @override
@ -312,7 +319,9 @@ class _TestButton extends StatelessWidget {
), ),
), ),
if (subtitle != null) if (subtitle != null)
Text( Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
subtitle!, subtitle!,
style: TextStyle( style: TextStyle(
fontSize: 16.sp, fontSize: 16.sp,
@ -320,6 +329,24 @@ class _TestButton extends StatelessWidget {
height: 0.85, height: 0.85,
), ),
), ),
),
Spacer(),
if (stats != null)
Padding(
padding: const EdgeInsets.only(right: 50.0),
child: Row(
children: [
Expanded(
child: HorizontalProgressWidget(
stats!.words.incorrect.round(),
stats!.words.total.round(),
color,
hasBorder: true,
),
),
],
),
),
], ],
), ),
), ),

View file

@ -7,16 +7,25 @@ class HorizontalProgressWidget extends StatelessWidget {
final int value; final int value;
final int length; final int length;
final Color color; final Color color;
final bool hasBorder;
HorizontalProgressWidget(this.value, this.length, this.color); HorizontalProgressWidget(
this.value,
this.length,
this.color, {
this.hasBorder = false,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ClipRRect( return Container(
borderRadius: BorderRadius.circular(16.h),
child: Container(
height: 16.h, height: 16.h,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: white, color: white,
border: hasBorder ? Border.all(color: color) : null,
borderRadius: BorderRadius.circular(16.h),
),
child: AnimatedFractionallySizedBox( child: AnimatedFractionallySizedBox(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
widthFactor: length == 0 ? 0 : value.toDouble() / length, widthFactor: length == 0 ? 0 : value.toDouble() / length,
@ -25,7 +34,6 @@ class HorizontalProgressWidget extends StatelessWidget {
color: color, color: color,
), ),
), ),
),
); );
} }
} }

View file

@ -1,269 +0,0 @@
import 'dart:developer';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:mnemo_cards/features/tests/test_manager.dart';
import 'package:mnemo_cards/features/tests/test_widgets/test_image.dart';
import 'package:mnemo_cards/utils/string_helper.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:shimmer/shimmer.dart';
import '../domain/router/app_router.gr.dart';
import '../managers/repository/repository.dart';
import '../theme/themes.dart';
import 'header.dart';
class TestsMenuWidget extends StatefulWidget {
final Repository repository;
final TestManager testManager;
final ScrollController? scrollController;
const TestsMenuWidget(
this.repository,
this.testManager, {
this.scrollController,
super.key,
});
@override
State<StatefulWidget> createState() => _TestsMenuWidgetState();
}
class _TestsMenuWidgetState extends State<TestsMenuWidget> {
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, constraints) {
final cardWidth = 125.w;
final cardHeight = cardWidth;
final theme = Theme.of(context);
return FutureBuilder(
future: widget.testManager.loadTests(),
builder: (context, snapshot) {
return Container(
alignment: Alignment.topCenter,
child: SingleChildScrollView(
controller: widget.scrollController,
physics: AlwaysScrollableScrollPhysics(),
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Column(
children: [
Header('Test'),
Padding(
padding: const EdgeInsets.all(8.0),
child: SizedBox(
height: cardHeight + 8,
child: ListView(
padding: EdgeInsets.all(4.0),
scrollDirection: Axis.horizontal,
children: [
if (snapshot.data == null)
...[1, 2, 3, 4].map(
(e) => _PackButtonShimmer(
cardWidth,
cardHeight,
),
),
if (snapshot.hasData)
...snapshot.data!.map((dto) =>
_TestButton(dto, cardWidth, cardHeight)),
],
),
),
),
],
),
),
),
);
},
);
});
}
Future<void> _precache(List<CardPackDto> packs) async {
for (final pack in packs) {
// if (pack.dto != null) {
// try {
// await precacheImage(MemoryImage(pack.cover!), context);
// } catch (e) {
// log('${pack.dto.id} cover not cached');
// }
// }
// for (final card in pack.cards.take(3)) {
// try {
// await precacheImage(card.image, context);
// } catch (_) {
// log('${pack.dto.id} - ${card.dto.id} image not cached');
// }
// }
}
}
}
class _CategoryTitle extends StatelessWidget {
final String text;
_CategoryTitle(this.text);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context).textTheme.titleLarge?.copyWith(
fontSize: 28.sp,
fontWeight: FontWeight.w800,
color: menuBlue,
);
return Container(
alignment: Alignment.centerLeft,
padding: EdgeInsets.only(left: 16.0),
child: Text(
text,
style: theme,
textAlign: TextAlign.start,
),
);
}
}
class _TestButton extends StatelessWidget {
final TestDto dto;
final double cardWidth;
final double cardHeight;
const _TestButton(
this.dto,
this.cardWidth,
this.cardHeight,
);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final textTheme = theme.textTheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 5.0),
child: InkWell(
onTap: () {
AutoRouter.of(context).push(
PageRouteInfo(TestPage.name, args: TestPageArgs(testId: dto.id!)),
);
},
child: Container(
width: cardWidth,
height: cardHeight,
padding: const EdgeInsets.all(4.0),
decoration: BoxDecoration(
color: dto.color?.asColor,
borderRadius: BorderRadius.circular(12.0),
boxShadow: const [
BoxShadow(
color: Colors.black26,
blurRadius: 4.0,
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
flex: 7,
child: (dto.cover != null)
? TestImageWidget(TestImage.image(dto.cover!))
: SizedBox.shrink(),
),
Expanded(
flex: 3,
child: Column(
children: [
Text(
'18',
style: textTheme.headlineSmall
?.copyWith(fontWeight: FontWeight.w800),
),
Text('из',
style: textTheme.labelLarge
?.copyWith(fontWeight: FontWeight.w800)),
Text(
'32',
style: textTheme.headlineSmall
?.copyWith(fontWeight: FontWeight.w800),
),
],
),
)
],
),
FittedBox(
fit: BoxFit.scaleDown,
child: Padding(
padding: const EdgeInsets.all(4.0),
child: Text(
dto.name,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 16.sp,
),
),
),
),
],
),
),
),
);
}
}
class _PackButtonShimmer extends StatelessWidget {
final double cardWidth;
final double cardHeight;
const _PackButtonShimmer(
this.cardWidth,
this.cardHeight,
);
@override
Widget build(BuildContext context) {
return Shimmer.fromColors(
baseColor: Colors.white,
highlightColor: Colors.grey[200]!,
child: Container(
width: cardWidth,
height: cardHeight,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
alignment: Alignment.center,
width: cardWidth,
height: cardWidth,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.0),
)),
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(8.0)),
color: Colors.white,
),
width: cardWidth * 0.7,
height: 30,
alignment: Alignment.center,
)
],
),
),
),
);
}
}