diff --git a/build/app/outputs/flutter-apk/app-arm64-v8a-release.apk b/build/app/outputs/flutter-apk/app-arm64-v8a-release.apk index 7547733..4417914 100644 Binary files a/build/app/outputs/flutter-apk/app-arm64-v8a-release.apk and b/build/app/outputs/flutter-apk/app-arm64-v8a-release.apk differ diff --git a/lib/admin/add_package.dart b/lib/admin/add_package.dart index d2d0459..608d71a 100644 --- a/lib/admin/add_package.dart +++ b/lib/admin/add_package.dart @@ -14,6 +14,7 @@ import 'package:mnemo_cards/widgets/header.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:file_picker/file_picker.dart'; + import '../main.dart'; import '../widgets/image_fade.dart'; @@ -256,7 +257,10 @@ class AddPackage with Api { 'Preview cards', cardPackDto.previewCards?.toString(), (v) { - final cards = (jsonDecode(v) as List).cast(); + final cards = v + .decodeList() + .map((e) => e.toString()) + .toList(); cardPackDto = cardPackDto.copyWith.previewCards(cards); }, ), diff --git a/lib/admin/admin_api.dart b/lib/admin/admin_api.dart index 1f8c9e4..faa1d86 100644 --- a/lib/admin/admin_api.dart +++ b/lib/admin/admin_api.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:async'; import 'dart:convert'; 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_common/mnemo_cards_common.dart'; + class AdminApi with Api { final Dio _dio; @@ -41,10 +41,7 @@ class AdminApi with Api { '$path/cards', queryParameters: {'ids': ids.join(',')}, ); - return (jsonDecode(r.data!) as List) - .map((e) => GameCardDto.fromJson(e as Map)) - .toList() - .cast(); + return (r.data as String).decodeList(GameCardDto.fromJson); } Future> getUsers(List ids) async { @@ -94,9 +91,7 @@ class AdminApi with Api { final r = await _dio.get( '$path/pack/edit/$id', ); - return EditCardPackDto.fromJson( - jsonDecode(r.data as String) as Map, - ); + return (r.data as String).decode(EditCardPackDto.fromJson); } Future deletePack(EditCardPackDto dto) async { diff --git a/lib/features/packs/pack_cache_manager.dart b/lib/features/packs/pack_cache_manager.dart index d99f1f6..8fd2736 100644 --- a/lib/features/packs/pack_cache_manager.dart +++ b/lib/features/packs/pack_cache_manager.dart @@ -6,6 +6,7 @@ import 'dart:typed_data'; import 'package:flutter/widgets.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart'; + import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -27,11 +28,12 @@ class PackCacheManager { final dir = await _packDirectory(packId); final file = File('${dir.path}/pack.json'); if (file.existsSync()) { - final json = jsonEncode(await file.readAsString()) as Map; + final json = (await file.readAsString()).decode(); json['id'] = '0'; await file.writeAsString(jsonEncode(json)); } } catch (e, s) { + log('Cant marck pack for update', error: e, stackTrace: s); Analytics.crashlyticsError( e: e, s: s, @@ -52,9 +54,7 @@ class PackCacheManager { log('Pack file not exist'); return throw Exception('Pack file not exist'); } - var pack = CardPackDto.fromJson( - jsonDecode(packFile.readAsStringSync()), - ); + var pack = packFile.readAsStringSync().decode(CardPackDto.fromJson); if (withCardImages) { Map images = {}; log('Cards ${packId}: ${pack.cards.length}'); @@ -147,7 +147,7 @@ class PackCacheManager { }) async { log('Saving pack ${dto.id}'); final dir = await _packDirectory(dto.id); - final json = jsonEncode(dto); + final json = dto.encode(); log('Saving dto ${dto.id}'); File('${dir.path}/pack.json') ..createSync(recursive: true) diff --git a/lib/features/packs/packs_api.dart b/lib/features/packs/packs_api.dart index 49e2516..650df21 100644 --- a/lib/features/packs/packs_api.dart +++ b/lib/features/packs/packs_api.dart @@ -1,4 +1,3 @@ -import 'dart:convert'; import 'dart:typed_data'; import 'package:dio/dio.dart'; @@ -19,10 +18,8 @@ class PacksApi with Api { '$path/packs/previews', queryParameters: packData, ); - final previews = (jsonDecode(r.data!) as List) - .cast>() - .map(CardPackPreviewDto.fromJson) - .toList(); + final previews = + (r.data! as String).decodeList(CardPackPreviewDto.fromJson).toList(); return previews; } @@ -34,10 +31,7 @@ class PacksApi with Api { '$path/packs/actions', queryParameters: packData, ); - final actions = (jsonDecode(r.data!) as List) - .cast>() - .map(CardPackAction.fromJson) - .toList(); + final actions = (r.data! as String).decodeList(CardPackAction.fromJson); return actions; } @@ -50,9 +44,7 @@ class PacksApi with Api { receiveTimeout: Duration(seconds: 5), ), ); - return CardPackDto.fromJson( - jsonDecode(r.data as String) as Map, - ); + return (r.data as String).decode(CardPackDto.fromJson); } // buy page dto if not available @@ -64,9 +56,7 @@ class PacksApi with Api { receiveTimeout: Duration(seconds: 5), ), ); - return CardPackBuyDto.fromJson( - jsonDecode(r.data as String) as Map, - ); + return (r.data as String).decode(CardPackBuyDto.fromJson); } /// cards zip archive for available pack diff --git a/lib/features/packs/preview_pack_holder.dart b/lib/features/packs/preview_pack_holder.dart index 3d3f26e..a239f38 100644 --- a/lib/features/packs/preview_pack_holder.dart +++ b/lib/features/packs/preview_pack_holder.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:developer'; import 'package:mnemo_cards_common/mnemo_cards_common.dart'; + import 'package:rxdart/rxdart.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -30,9 +31,7 @@ class PreviewPackHolder { final List previewPacks = []; previewPacksStrings.forEach((string) { try { - final pack = CardPackPreviewDto.fromJson( - jsonDecode(string) as Map, - ); + final pack = string.decode(CardPackPreviewDto.fromJson); previewPacks.add(pack); } catch (e, s) { log('Cant load preview pack', error: e, stackTrace: s); diff --git a/lib/features/tests/progress_widget.dart b/lib/features/tests/progress_widget.dart index 404e998..e587488 100644 --- a/lib/features/tests/progress_widget.dart +++ b/lib/features/tests/progress_widget.dart @@ -2,9 +2,11 @@ import 'dart:math'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:mnemo_cards/flags.dart'; import 'package:rxdart/rxdart.dart'; import '../../theme/themes.dart'; @@ -122,6 +124,10 @@ class _ProgressWidgetState extends State { ), ], ), + if (kDebugMode) + Text( + '${(results.where((r) => r != Result.not_visited).length)}/${results.length}', + ) ], ), ), diff --git a/lib/features/tests/question_states/input_buttons_test_state.dart b/lib/features/tests/question_states/input_buttons_test_state.dart index 36321d4..37a8ca4 100644 --- a/lib/features/tests/question_states/input_buttons_test_state.dart +++ b/lib/features/tests/question_states/input_buttons_test_state.dart @@ -25,6 +25,6 @@ class InputButtonsTestState extends TestQuestionState { this.isCorrect = false, this.isAnswered = false, this.isSkipped = false, - super.testType = TestQuestionType.simple, + super.testType = TestQuestionType.input_buttons, }); } diff --git a/lib/features/tests/test_complete_widget.dart b/lib/features/tests/test_complete_widget.dart index 76c9d48..d39cacd 100644 --- a/lib/features/tests/test_complete_widget.dart +++ b/lib/features/tests/test_complete_widget.dart @@ -6,6 +6,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:mnemo_cards/utils/color_helper.dart'; import 'package:rxdart/rxdart.dart'; +import '../../di/locator.dart'; import '../../widgets/big_back_button.dart'; import '../../widgets/horizontal_progress.dart'; import 'question_states/test_question_state.dart'; @@ -37,132 +38,143 @@ class TestCompleteWidget extends StatelessWidget { ) .values .toList() - ..sort((p, n) => p.correctCount.compareTo(n.correctCount)); - return Column( - children: [ - Expanded( - flex: 3, - child: Padding( - padding: const EdgeInsets.only(top: 12.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: MediaQuery.of(context).size.width / 1.8, - height: MediaQuery.of(context).size.width / 1.8, - padding: const EdgeInsets.all(20.0), - child: StreamBuilder( - stream: Stream.fromIterable([5]) - .interval(Duration(milliseconds: 0)), - initialData: 0, - builder: (context, snapshot) { - final a = min(snapshot.requireData / 5.0, 1.0); - return PieChart( - key: _pieChartKey, - swapAnimationDuration: Duration(milliseconds: 600), - PieChartData( - pieTouchData: PieTouchData(), - borderData: FlBorderData( - show: false, + ..sort( + (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: [ + Expanded( + flex: 3, + child: Padding( + padding: const EdgeInsets.only(top: 12.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: MediaQuery.of(context).size.width / 1.8, + height: MediaQuery.of(context).size.width / 1.8, + padding: const EdgeInsets.all(20.0), + child: StreamBuilder( + stream: Stream.fromIterable([5]) + .interval(Duration(milliseconds: 0)), + initialData: 0, + builder: (context, snapshot) { + final a = min(snapshot.requireData / 5.0, 1.0); + return PieChart( + key: _pieChartKey, + swapAnimationDuration: Duration(milliseconds: 600), + PieChartData( + pieTouchData: PieTouchData(), + borderData: FlBorderData( + show: false, + ), + sectionsSpace: 0, + centerSpaceRadius: + MediaQuery.of(context).size.width / 8, + sections: [ + PieChartSectionData( + color: successColor, + showTitle: false, + value: correct.toDouble() * a, + badgeWidget: Image.asset( + 'icons/crown.png', + width: 20.w, + ), + ), + PieChartSectionData( + color: successColor.lighten(0.15), + showTitle: false, + value: incorrect.toDouble() * a, + badgeWidget: Image.asset( + 'icons/skull.png', + width: 20.w, + ), + ), + PieChartSectionData( + color: Colors.white, + showTitle: false, + value: skipped.toDouble(), + badgeWidget: Image.asset( + 'icons/small_circle.png', + width: 20.w, + ), + ), + ], ), - sectionsSpace: 0, - centerSpaceRadius: - MediaQuery.of(context).size.width / 8, - sections: [ - PieChartSectionData( - color: successColor, - showTitle: false, - value: correct.toDouble() * a, - badgeWidget: Image.asset( - 'icons/crown.png', - width: 20.w, - ), - ), - PieChartSectionData( - color: successColor.lighten(0.15), - showTitle: false, - value: incorrect.toDouble() * a, - badgeWidget: Image.asset( - 'icons/skull.png', - width: 20.w, - ), - ), - PieChartSectionData( - color: Colors.white, - showTitle: false, - value: skipped.toDouble(), - badgeWidget: Image.asset( - 'icons/small_circle.png', - width: 20.w, - ), - ), - ], - ), - ); - }), - ), - Text( - '$correct/$total', - style: TextStyle( - fontSize: 36, - fontWeight: FontWeight.w500, + ); + }), ), - ), - Text( - '1:47', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w500, - height: 0.85, + Text( + '$correct/$total', + style: TextStyle( + fontSize: 36, + fontWeight: FontWeight.w500, + ), ), - ), - ], + Text( + '1:47', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w500, + height: 0.85, + ), + ), + ], + ), ), ), - ), - Expanded( - flex: 2, - child: Container( - alignment: Alignment.bottomCenter, - child: ListView.builder( - shrinkWrap: true, - itemCount: words.length, - itemBuilder: (context, index) { - final word = words[index]; - return Padding( - padding: const EdgeInsets.only( - left: 12.0, - right: 24, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '${word.word}', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.w500, - ), - ), - Flexible( - child: SizedBox( - width: MediaQuery.of(context).size.width / 2.5, - child: HorizontalProgressWidget( - word.correctCount, - word.totalCount, - successColor, + Expanded( + flex: 2, + child: Container( + alignment: Alignment.bottomCenter, + child: ListView.builder( + shrinkWrap: true, + itemCount: words.length, + itemBuilder: (context, index) { + final word = words[index]; + return Padding( + padding: const EdgeInsets.only( + left: 12.0, + right: 24, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${word.word}', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w500, ), ), - ), - ], - ), - ); - }, + Flexible( + child: SizedBox( + width: MediaQuery.of(context).size.width / 2.5, + child: HorizontalProgressWidget( + word.correctCount, + word.totalCount, + successColor, + ), + ), + ), + ], + ), + ); + }, + ), ), ), - ), - const BigBackButton(), - ], + const BigBackButton(), + ], + ), ); } } diff --git a/lib/features/tests/test_manager.dart b/lib/features/tests/test_manager.dart index 637d8fc..701df54 100644 --- a/lib/features/tests/test_manager.dart +++ b/lib/features/tests/test_manager.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:developer'; 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_widgets/test_image.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 { final PackCacheManager packCacheManager; final HttpRepository _repository; - final List questions = []; - Color color = borderGray; final PageController pageController = PageController(); final TestHolder testHolder = TestHolder(); + final StreamController _triggerController = + StreamController.broadcast(); - Future> loadTests({String? packId}) async { - final tests = (await _repository.getTests(packId: packId ?? '')) - .map((e) => TestDto.fromJson(e)) - .toList(); + TestDto? _test; + + Color get color => _test?.color?.asColor ?? borderGray; + + List get questions => _test?.questions ?? []; + + void triggerPoll() => _triggerController.add(1); + + Future> pollTests({String? packId}) async { + print('polling tests'); + final tests = (await _repository.getTests(packId: packId ?? '')); return tests; } @@ -36,12 +44,16 @@ class TestManager extends ChangeNotifier { } Stream> loadTestsStream({String? packId}) { - return Stream.periodic( - Duration(seconds: 60), + return Rx.combineLatest2( + _triggerController.stream.startWith(null), + Stream.periodic( + Duration(seconds: 120), + ).startWith(null), + (_, __) => null, ) - .startWith(null) + .throttleTime(Duration(seconds: 1)) .asyncMap( - (tests) async => loadTests(packId: packId) + (tests) async => pollTests(packId: packId) .catchError((_) => Future.value([])), ) .where((test) => test.isNotEmpty); @@ -56,19 +68,20 @@ class TestManager extends ChangeNotifier { if (packId != null) { await packCacheManager.loadPackFromCache(packId); } - TestDto? test; try { - test = await _repository.getTest(id); + _test = await _repository.getTest(id); } 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(); + return; } - questions.clear(); - questions.addAll(test!.questions); - color = test.color?.asColor ?? borderGray; - testHolder.setQuestions(questions); + // todo use uuid + testHolder.setQuestions( + _test!.questions, + DateTime.timestamp().toString(), + ); } void setState(int id, TestQuestionState state) { @@ -153,4 +166,28 @@ class TestManager extends ChangeNotifier { final bytes = await packCacheManager.loadPackFile(packId, path); return MemoryImage(bytes!); } + + Future sendStatistics() { + final words = states + .fold({}, (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); + } } diff --git a/lib/features/tests/test_page.dart b/lib/features/tests/test_page.dart index e106cb0..6586967 100644 --- a/lib/features/tests/test_page.dart +++ b/lib/features/tests/test_page.dart @@ -2,9 +2,12 @@ import 'dart:developer'; import 'dart:math' as math; import 'package:auto_route/auto_route.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.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:rxdart/rxdart.dart'; import '../../di/locator.dart'; import 'scroll_physics.dart'; @@ -26,6 +29,9 @@ class TestPage extends StatefulWidget { class _TestPageState extends State { PageController get pageController => locator.testManager.pageController; + int? lastSkipIndex; + int? _lastAudioQuestionId; + @override void initState() { super.initState(); @@ -41,25 +47,46 @@ class _TestPageState extends State { } 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) { final prevId = locator.testManager.questions[index - 1].id!; final state = locator.testManager.getState(prevId); if (state?.isAnswered == false) { - // log('Question skipped'); + print('Skipping $index ${state?.word}'); locator.testManager.skip(prevId); + lastSkipIndex = index; } } } - int lastTestIndex = 0; - @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.white, body: StreamBuilder( - stream: locator.testManager.testHolder.asStream, + stream: locator.testManager.testHolder.asStream.takeWhileInclusive( + (state) => state?.questions.isNotEmpty != true, + ), builder: (context, s) { if (!s.hasData || s.requireData!.questions.isEmpty) { return Scaffold( @@ -69,6 +96,16 @@ class _TestPageState extends State { ); } + WidgetsBinding.instance.addPostFrameCallback((_) { + _pageScrollListener(); + }); + + final keys = List.generate( + locator.testManager.questions.length, + (v) => + GlobalKey(debugLabel: locator.testManager.questions[v].word), + ); + return Scaffold( backgroundColor: locator.testManager.color.withOpacity(0.25), body: SafeArea( @@ -100,27 +137,55 @@ class _TestPageState extends State { locator.testManager.color, ); } - - lastTestIndex = math.max(index, lastTestIndex); - return switch (locator.testManager - .getTest(index) - .questionType) { + final question = locator.testManager.getTest(index); + return switch (question.questionType) { TestQuestionType.simple => SimpleTestWidget( - locator.testManager.getTest(index) - as SimpleTestQuestionBody, + question as SimpleTestQuestionBody, + key: keys[index], ), TestQuestionType.input_buttons => InputButtonsTestWidget( - locator.testManager.getTest(index) - as InputButtonsTestQuestionBody, - ), + question as InputButtonsTestQuestionBody, + key: keys[index]), TestQuestionType.matrix => Container(), TestQuestionType.match => Container(), // TODO: Handle this case. TestQuestionType.undefined => 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), + ), ), ), ], diff --git a/lib/features/tests/test_progress_widget.dart b/lib/features/tests/test_progress_widget.dart index 251c1b7..2741c40 100644 --- a/lib/features/tests/test_progress_widget.dart +++ b/lib/features/tests/test_progress_widget.dart @@ -25,11 +25,12 @@ class _TestProgressWidgetState extends State { Stream? timer; - Timer? _updateTimer; + bool _active = false; void _updateDataListener() { - if (_updateTimer == null || !_updateTimer!.isActive) { - _updateTimer = Timer(Duration(milliseconds: 100), _updateData); + if (!_active) { + _active = true; + Timer(Duration(milliseconds: 100), () => _updateData()); } } @@ -53,16 +54,16 @@ class _TestProgressWidgetState extends State { results.add(Result.not_visited); } } + print('Update data ${results.map((e) => e.name).join(' ')}'); if (mounted) { setState(() {}); } + _active = false; } @override void initState() { super.initState(); - // locator.testManager.removeListener(_updateData); - // locator.testManager.addListener(_updateData); locator.testManager.pageController.removeListener(_updateDataListener); locator.testManager.pageController.addListener(_updateDataListener); timer = Stream.periodic( @@ -79,7 +80,6 @@ class _TestProgressWidgetState extends State { length, locator.testManager.color, timer: timer, - key: ValueKey('TEST PROGRESS'), ); } } diff --git a/lib/features/tests/test_state_holder.dart b/lib/features/tests/test_state_holder.dart index 2d5b37a..f76200d 100644 --- a/lib/features/tests/test_state_holder.dart +++ b/lib/features/tests/test_state_holder.dart @@ -13,17 +13,20 @@ class TestHolder { StreamController.broadcast(); _TestState __state; - TestHolder() : __state = _TestState({}); + TestHolder() : __state = _TestState({}, null); void skip(int id) { final state = testQuestionState(id); + print('Skipping $id ${state} ${state?.testType}'); switch (state?.testType) { case TestQuestionType.simple: state as SimpleTestState; + print('Skipped $id ${state.word}'); setQuestionState(id, state.copyWith(isSkipped: true)); break; case TestQuestionType.input_buttons: state as InputButtonsTestState; + print('Skipped $id ${state.word}'); setQuestionState(id, state.copyWith(isSkipped: true)); break; case TestQuestionType.matrix: @@ -40,16 +43,20 @@ class TestHolder { } } - void setQuestionState(int id, TestQuestionState state) => _state = _TestState( - Map.from(_state.questions..[id] = state), - ); + void setQuestionState(int id, TestQuestionState state) { + _state = _TestState( + Map.from(_state.questions..[id] = state), + _state.sessionToken, + ); + } void clear() { - _state = _TestState({}); + _state = _TestState({}, null); _streamController.add(_state); } - void setQuestions(List questions) { + void setQuestions( + List questions, String? sessionToken) { final stateQuestions = {}; final seed = Random().nextInt(999); for (final question in questions) { @@ -72,7 +79,7 @@ class TestHolder { // TODO: Handle this case. } } - _state = _TestState(stateQuestions); + _state = _TestState(stateQuestions, sessionToken); } TestQuestionState? testQuestionState(int id) => _state.questions[id]; @@ -91,7 +98,8 @@ class TestHolder { } class _TestState { + final String? sessionToken; final Map questions; - _TestState(this.questions); + _TestState(this.questions, this.sessionToken); } diff --git a/lib/features/tests/test_widgets/common_test_question_widget.dart b/lib/features/tests/test_widgets/common_test_question_widget.dart index f9d0add..932d727 100644 --- a/lib/features/tests/test_widgets/common_test_question_widget.dart +++ b/lib/features/tests/test_widgets/common_test_question_widget.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_tts/flutter_tts.dart'; +import 'package:mnemo_cards/managers/audio_player.dart'; import '../../../main.dart'; import '../../../widgets/game_card_widget.dart'; @@ -11,15 +12,17 @@ class CommonTestQuestionWidget extends StatelessWidget { final String? image; final String? audio; - CommonTestQuestionWidget({ + const CommonTestQuestionWidget({ this.text, this.image, this.audio, + super.key, }); @override Widget build(BuildContext context) { final theme = Theme.of(context).textTheme; + return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -49,7 +52,7 @@ class CommonTestQuestionWidget extends StatelessWidget { 'icons/sound_on.png', width: 26, ), - onPressed: () => _playAudio(), + onPressed: () => AudioPlayer.playAudio(audio), ), ), ], @@ -66,7 +69,7 @@ class CommonTestQuestionWidget extends StatelessWidget { width: 80, height: 80, ), - onPressed: () => _playAudio(), + onPressed: () => AudioPlayer.playAudio(audio), ), ), ), @@ -91,7 +94,7 @@ class CommonTestQuestionWidget extends StatelessWidget { width: 26, height: theme.headlineLarge?.fontSize, ), - onPressed: () => _playAudio(), + onPressed: () => AudioPlayer.playAudio(audio), ), ) ], @@ -100,18 +103,4 @@ class CommonTestQuestionWidget extends StatelessWidget { ], ); } - - Future _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); - } - } } diff --git a/lib/features/tests/test_widgets/simple_test.dart b/lib/features/tests/test_widgets/simple_test.dart index 4e62b90..668d7c6 100644 --- a/lib/features/tests/test_widgets/simple_test.dart +++ b/lib/features/tests/test_widgets/simple_test.dart @@ -27,12 +27,13 @@ class SimpleTestWidget extends StatelessWidget { Color get testColor => manager.color; - SimpleTestWidget(this._simpleTestModel, {super.key}); + const SimpleTestWidget(this._simpleTestModel, {super.key}); @override Widget build(BuildContext context) { final random = Random(state.seed); final buttons = [...model.buttons]..shuffle(random); + return LayoutBuilder(builder: (context, constraints) { return Padding( padding: const EdgeInsets.all(8.0), diff --git a/lib/managers/audio_player.dart b/lib/managers/audio_player.dart new file mode 100644 index 0000000..35f93cb --- /dev/null +++ b/lib/managers/audio_player.dart @@ -0,0 +1,19 @@ +import 'package:flutter_tts/flutter_tts.dart'; + +import '../main.dart'; + +class AudioPlayer { + static Future 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); + } + } +} diff --git a/lib/managers/repository/http_repository.dart b/lib/managers/repository/http_repository.dart index 685cc45..a516f98 100644 --- a/lib/managers/repository/http_repository.dart +++ b/lib/managers/repository/http_repository.dart @@ -1,39 +1,18 @@ import 'dart:convert'; import 'dart:developer'; import 'dart:io'; -import 'package:device_info_plus/device_info_plus.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/user_manager.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 'firebase_config_repository.dart'; class HttpRepository extends Repository with Api { final Dio _dio; HttpRepository(this._dio); - @override - Future>> getPacks( - Map? packData) async { - final r = await _dio.get( - '$path/packs/', - queryParameters: packData, - ); - final ll = - (jsonDecode(r.data!) as List).cast>(); - return ll; - } - @override Future get user async => UserDto.empty; @@ -58,12 +37,7 @@ class HttpRepository extends Repository with Api { }); //todo add client header encryption ?? final authToken = r.headers[HttpHeaders.authorizationHeader]!.last; - return ( - UserDto.fromJson( - jsonDecode(r.data as String) as Map, - ), - authToken, - ); + return ((r.data as String).decode(UserDto.fromJson), authToken); } on DioException catch (e, s) { log('User create error', error: e, stackTrace: s); rethrow; @@ -83,24 +57,35 @@ class HttpRepository extends Repository with Api { Future getUser() async { final r = await _dio.get('$path/user'); if (r.statusCode == 200) { - return UserDto.fromJson( - jsonDecode(r.data as String) as Map, - ); + return (r.data as String).decode(UserDto.fromJson); } return null; } @override - Future updateUserData(String id, Map data) async {} + Future updateUserData(UserDataDto data) async { + final r = await _dio.post('$path/user/data', data: data.encode()); + } @override - Future>> getTests({String packId = ''}) async { + Future updateUserSettings(UserSettingsDto settings) async { + final r = await _dio.post('$path/user/settings', data: settings.encode()); + } + + @override + Future addTestStatistics(TestStatisticsDto testStatistics) async { + final r = await _dio.post( + '$path/user/data/add/test-statistics', + data: testStatistics.encode(), + ); + } + + @override + Future> getTests({String packId = ''}) async { final r = await _dio.get( '$path/tests/$packId', ); - final ll = - (jsonDecode(r.data!) as List).cast>(); - return ll; + return (r.data as String).decodeList(TestDto.fromJson); } @override @@ -109,9 +94,7 @@ class HttpRepository extends Repository with Api { '$path/test/$id', ); try { - return TestDto.fromJson( - jsonDecode(r.data as String) as Map, - ); + return (r.data as String).decode(TestDto.fromJson); } catch (e, s) { log(e.toString(), stackTrace: s); return null; diff --git a/lib/managers/repository/repository.dart b/lib/managers/repository/repository.dart index 451774d..05c2dfb 100644 --- a/lib/managers/repository/repository.dart +++ b/lib/managers/repository/repository.dart @@ -18,9 +18,13 @@ abstract class Repository { Future getUser(); - Future updateUserData(String id, Map data); + Future updateUserData(UserDataDto data); - Future>> getTests(); + Future updateUserSettings(UserSettingsDto settings); + + Future addTestStatistics(TestStatisticsDto testStatistics); + + Future> getTests(); Future getTest(String id); diff --git a/lib/managers/user_manager.dart b/lib/managers/user_manager.dart index 2d10ad0..20df53a 100644 --- a/lib/managers/user_manager.dart +++ b/lib/managers/user_manager.dart @@ -123,7 +123,7 @@ class UserManager { .listen((user) async { try { if (user == null) { - AppRouter.openAuthOrProfile(); + AppRouter.openAuthOrProfile(); } else { try { AppRouter.closeAuthPage(); diff --git a/lib/widgets/card_pack/available_pack.dart b/lib/widgets/card_pack/available_pack.dart index d100723..6c330e2 100644 --- a/lib/widgets/card_pack/available_pack.dart +++ b/lib/widgets/card_pack/available_pack.dart @@ -13,6 +13,7 @@ import '../../features/yandex_ads/yandex_ads.dart'; import '../../di/locator.dart'; import '../../theme/themes.dart'; +import '../horizontal_progress.dart'; import 'card_pack_header.dart'; import 'package:mnemo_cards/utils/string_helper.dart'; @@ -180,6 +181,8 @@ class _TestsSection extends StatelessWidget { child: _TestButton( title: dto.name, subtitle: null, + stats: dto.statistics, + color: dto.color?.asColor ?? borderGray, time: dto.time, timeSubtitle: dto.timeSubtitle, onTap: () { @@ -199,11 +202,10 @@ class _TestsSection extends StatelessWidget { } class _AdTile extends StatelessWidget { - const _AdTile({super.key}); + const _AdTile(); @override Widget build(BuildContext context) { - final theme = Theme.of(context).textTheme; return LayoutBuilder(builder: (context, constraints) { final banner = YandexAds.createBanner( BoxConstraints.tight(Size(constraints.maxWidth - 16, 80 - 8))); @@ -265,13 +267,18 @@ class _TestButton extends StatelessWidget { final String? subtitle; final String? time; final String? timeSubtitle; + final Color color; + final TestStatisticsDto? stats; _TestButton({ required this.title, required this.onTap, + required this.color, this.subtitle, this.time, this.timeSubtitle, + this.stats, + super.key, }); @override @@ -312,12 +319,32 @@ class _TestButton extends StatelessWidget { ), ), if (subtitle != null) - Text( - subtitle!, - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w300, - height: 0.85, + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + subtitle!, + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w300, + 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, + ), + ), + ], ), ), ], diff --git a/lib/widgets/horizontal_progress.dart b/lib/widgets/horizontal_progress.dart index b567788..e956341 100644 --- a/lib/widgets/horizontal_progress.dart +++ b/lib/widgets/horizontal_progress.dart @@ -7,23 +7,31 @@ class HorizontalProgressWidget extends StatelessWidget { final int value; final int length; final Color color; + final bool hasBorder; - HorizontalProgressWidget(this.value, this.length, this.color); + HorizontalProgressWidget( + this.value, + this.length, + this.color, { + this.hasBorder = false, + }); @override Widget build(BuildContext context) { - return ClipRRect( - borderRadius: BorderRadius.circular(16.h), - child: Container( - height: 16.h, + return Container( + height: 16.h, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( color: white, - child: AnimatedFractionallySizedBox( - alignment: Alignment.centerLeft, - widthFactor: length == 0 ? 0 : value.toDouble() / length, - duration: Duration(milliseconds: 600), - child: Container( - color: color, - ), + border: hasBorder ? Border.all(color: color) : null, + borderRadius: BorderRadius.circular(16.h), + ), + child: AnimatedFractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: length == 0 ? 0 : value.toDouble() / length, + duration: Duration(milliseconds: 600), + child: Container( + color: color, ), ), ); diff --git a/lib/widgets/tests_menu_widget.dart b/lib/widgets/tests_menu_widget.dart deleted file mode 100644 index 08c2b0a..0000000 --- a/lib/widgets/tests_menu_widget.dart +++ /dev/null @@ -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 createState() => _TestsMenuWidgetState(); -} - -class _TestsMenuWidgetState extends State { - @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 _precache(List 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, - ) - ], - ), - ), - ), - ); - } -}