From 7aecd2dd09cb048ca375acfd5e8febc03bbd8181 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Wed, 29 May 2024 00:25:51 +0300 Subject: [PATCH] stuff --- icons/down.png | Bin 0 -> 976 bytes icons/heart_fill.png | Bin 0 -> 8471 bytes lib/admin/add_card.dart | 6 +- lib/admin/admin_api.dart | 6 +- lib/di/injector.dart | 5 +- lib/di/locator.dart | 3 + lib/di/module.dart | 0 lib/domain/router/app_router.gr.dart | 49 ++- lib/features/analytics/analytics.dart | 173 +++++++++ lib/features/packs/images_holder.dart | 23 +- lib/features/packs/pack_cache_manager.dart | 6 +- lib/features/packs/pack_manager.dart | 53 ++- lib/features/packs/pack_updater.dart | 6 +- lib/features/packs/preview_pack_holder.dart | 51 ++- lib/features/packs/preview_packs_poller.dart | 2 +- lib/features/purchase/in_app_purchase.dart | 75 +++- .../input_buttons_test_state.dart | 4 +- .../input_buttons_test_state.g.dart | 25 +- lib/features/tests/test_manager.dart | 24 +- lib/features/tests/test_page.dart | 2 +- .../test_widgets/input_buttons_test.dart | 304 +++++++++------ lib/flags.dart | 3 +- lib/main.dart | 30 +- lib/managers/favorite_cards.dart | 2 +- lib/managers/repository/api.dart | 3 +- lib/managers/user_manager.dart | 10 +- lib/managers/user_state_holder.dart | 4 + lib/pages/profile_page.dart | 7 +- lib/widgets/card_game/card_game_main.dart | 78 +--- lib/widgets/card_game/card_game_page.dart | 61 ++- lib/widgets/card_pack/available_pack.dart | 365 ++++++------------ lib/widgets/card_pack/buy_pack_page.dart | 192 ++++++--- lib/widgets/card_pack/card_pack_page.dart | 58 +-- lib/widgets/card_pack/cards_view.dart | 263 +++++++++++++ .../card_pack/cards_view_controller.dart | 126 ++++++ lib/widgets/card_pack/heart_button.dart | 10 + lib/widgets/card_pack/loading_pack.dart | 44 +++ lib/widgets/fade_in_widget.dart | 29 ++ lib/widgets/game_card_widget.dart | 78 ++-- lib/widgets/image_fade.dart | 3 +- lib/widgets/items/builder.dart | 2 + lib/widgets/packs_menu_widget.dart | 23 +- pubspec.lock | 48 ++- pubspec.yaml | 2 + 44 files changed, 1578 insertions(+), 680 deletions(-) create mode 100644 icons/down.png create mode 100644 icons/heart_fill.png delete mode 100644 lib/di/module.dart create mode 100644 lib/features/analytics/analytics.dart create mode 100644 lib/widgets/card_pack/cards_view.dart create mode 100644 lib/widgets/card_pack/cards_view_controller.dart create mode 100644 lib/widgets/card_pack/heart_button.dart create mode 100644 lib/widgets/card_pack/loading_pack.dart create mode 100644 lib/widgets/fade_in_widget.dart diff --git a/icons/down.png b/icons/down.png new file mode 100644 index 0000000000000000000000000000000000000000..581d80db0f88e03970e0944caadd79c838737981 GIT binary patch literal 976 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

} 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 literal 0 HcmV?d00001 diff --git a/lib/admin/add_card.dart b/lib/admin/add_card.dart index 45f2f06..c935b0b 100644 --- a/lib/admin/add_card.dart +++ b/lib/admin/add_card.dart @@ -79,7 +79,7 @@ class EditCard { final TextEditingController mnemoController = TextEditingController(text: gameCardDto.mnemo); final TextEditingController backController = - TextEditingController(text: gameCardDto.back); + TextEditingController(text: gameCardDto.back); showDialog( context: context, @@ -274,7 +274,9 @@ class EditCard { TextField( controller: backController, onChanged: (v) { - gameCardDto = gameCardDto.copyWith(back: v); + gameCardDto = gameCardDto.copyWith( + back: v, + ); }, decoration: InputDecoration( hintText: 'Задняя сторона', diff --git a/lib/admin/admin_api.dart b/lib/admin/admin_api.dart index 0e2307f..0606e6d 100644 --- a/lib/admin/admin_api.dart +++ b/lib/admin/admin_api.dart @@ -14,8 +14,10 @@ class AdminApi with Api { AdminApi(this._dio); Future addCard(GameCardDto dto) async { - final r = await _dio.post('$path/cards/add', - data: jsonEncode(dto.toJson())); + final r = await _dio.post( + '$path/cards/add', + data: jsonEncode(dto.toJson()), + ); return r.statusCode == 200; } diff --git a/lib/di/injector.dart b/lib/di/injector.dart index 8feea83..4c28b00 100644 --- a/lib/di/injector.dart +++ b/lib/di/injector.dart @@ -24,7 +24,9 @@ import '../managers/user_manager.dart'; GetIt getIt = GetIt.I; Future setInjections() async { - getIt.registerSingletonAsync(() => DioProvider().init()); + getIt.registerSingletonAsync( + () => DioProvider().init(), + ); getIt.registerLazySingleton( () => HttpRepository(getIt.get().dio), ); @@ -96,4 +98,5 @@ Future setInjections() async { getIt.registerLazySingleton( () => PurchaseDetailsStreamSubscription(), ); + await getIt.allReady(); } diff --git a/lib/di/locator.dart b/lib/di/locator.dart index 8d740ea..e5bfe9b 100644 --- a/lib/di/locator.dart +++ b/lib/di/locator.dart @@ -1,4 +1,5 @@ import 'package:get_it/get_it.dart'; +import 'package:mnemo_cards/features/packs/preview_pack_holder.dart'; import 'package:mnemo_cards/features/packs/preview_packs_poller.dart'; import 'package:mnemo_cards/managers/favorite_cards.dart'; import 'package:mnemo_cards/managers/repository/http_repository.dart'; @@ -32,6 +33,8 @@ class Locator { PackUpdater get packUpdater => _getIt.get(); + PreviewPackHolder get previewPackHolder => _getIt.get(); + PreviewPacksPoller get previewPackPoller => _getIt.get(); TestManager get testManager => _getIt.get(); diff --git a/lib/di/module.dart b/lib/di/module.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/domain/router/app_router.gr.dart b/lib/domain/router/app_router.gr.dart index 0b46b2d..b991536 100644 --- a/lib/domain/router/app_router.gr.dart +++ b/lib/domain/router/app_router.gr.dart @@ -8,8 +8,11 @@ // coverage:ignore-file // ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:ui' as _i11; + import 'package:auto_route/auto_route.dart' as _i9; -import 'package:flutter/cupertino.dart' as _i11; +import 'package:flutter/cupertino.dart' as _i13; +import 'package:flutter/material.dart' as _i12; import 'package:mnemo_cards/features/tests/test_page.dart' as _i8; import 'package:mnemo_cards/main.dart' as _i6; import 'package:mnemo_cards/pages/auth_page.dart' as _i1; @@ -36,9 +39,11 @@ abstract class $AppRouter extends _i9.RootStackRouter { return _i9.AutoRoutePage( routeData: routeData, child: _i2.CardGamePage( - cardPack: args.cardPack, + cards: args.cards, + color: args.color, startCardId: args.startCardId, infinite: args.infinite, + shuffle: args.shuffle, key: args.key, ), ); @@ -48,7 +53,7 @@ abstract class $AppRouter extends _i9.RootStackRouter { return _i9.AutoRoutePage( routeData: routeData, child: _i3.CardPackPage( - args.cardPackId, + args.previewDto, key: args.key, ), ); @@ -105,17 +110,21 @@ class AuthPage extends _i9.PageRouteInfo { /// [_i2.CardGamePage] class CardGamePage extends _i9.PageRouteInfo { CardGamePage({ - required _i10.CardPackDto cardPack, + required List<_i10.GameCardDto> cards, + required _i11.Color? color, int? startCardId, bool infinite = false, - _i11.Key? key, + bool shuffle = true, + _i12.Key? key, List<_i9.PageRouteInfo>? children, }) : super( CardGamePage.name, args: CardGamePageArgs( - cardPack: cardPack, + cards: cards, + color: color, startCardId: startCardId, infinite: infinite, + shuffle: shuffle, key: key, ), initialChildren: children, @@ -129,23 +138,29 @@ class CardGamePage extends _i9.PageRouteInfo { class CardGamePageArgs { const CardGamePageArgs({ - required this.cardPack, + required this.cards, + required this.color, this.startCardId, this.infinite = false, + this.shuffle = true, this.key, }); - final _i10.CardPackDto cardPack; + final List<_i10.GameCardDto> cards; + + final _i11.Color? color; final int? startCardId; final bool infinite; - final _i11.Key? key; + final bool shuffle; + + final _i12.Key? key; @override String toString() { - return 'CardGamePageArgs{cardPack: $cardPack, startCardId: $startCardId, infinite: $infinite, key: $key}'; + return 'CardGamePageArgs{cards: $cards, color: $color, startCardId: $startCardId, infinite: $infinite, shuffle: $shuffle, key: $key}'; } } @@ -153,13 +168,13 @@ class CardGamePageArgs { /// [_i3.CardPackPage] class CardPackPage extends _i9.PageRouteInfo { CardPackPage({ - required String cardPackId, - _i11.Key? key, + required _i10.CardPackPreviewDto previewDto, + _i13.Key? key, List<_i9.PageRouteInfo>? children, }) : super( CardPackPage.name, args: CardPackPageArgs( - cardPackId: cardPackId, + previewDto: previewDto, key: key, ), initialChildren: children, @@ -173,17 +188,17 @@ class CardPackPage extends _i9.PageRouteInfo { class CardPackPageArgs { const CardPackPageArgs({ - required this.cardPackId, + required this.previewDto, this.key, }); - final String cardPackId; + final _i10.CardPackPreviewDto previewDto; - final _i11.Key? key; + final _i13.Key? key; @override String toString() { - return 'CardPackPageArgs{cardPackId: $cardPackId, key: $key}'; + return 'CardPackPageArgs{previewDto: $previewDto, key: $key}'; } } diff --git a/lib/features/analytics/analytics.dart b/lib/features/analytics/analytics.dart new file mode 100644 index 0000000..9eac5f6 --- /dev/null +++ b/lib/features/analytics/analytics.dart @@ -0,0 +1,173 @@ +import 'package:firebase_analytics/firebase_analytics.dart'; +import 'package:firebase_crashlytics/firebase_crashlytics.dart'; +import 'package:mnemo_cards/managers/user_manager.dart'; +import 'package:mnemo_cards_common/mnemo_cards_common.dart'; + +class Analytics { + static final _analytics = FirebaseAnalytics.instance; + static final _crashlytics = FirebaseCrashlytics.instance; + + static Future setUser(UserDto user) { + return _analytics.setUserId(id: user.id?.toString()); + } + + static Future login(ExternalIdType idType) { + return _analytics.logLogin(loginMethod: idType.name); + } + + static Future logout(UserDto? dto) async { + await _analytics.logEvent( + name: 'logout', + parameters: { + 'id': dto?.id, + }.asFirebaseMap, + ); + return _analytics.setUserId(id: dto?.id?.toString()); + } + + static Future profilePageOpened(UserDto? dto) { + return _analytics.logScreenView( + screenClass: 'profile', + screenName: 'profile', + parameters: { + 'id': dto?.id, + }.asFirebaseMap); + } + + static Future availablePackOpened(CardPackDto dto) { + return _analytics.logScreenView( + screenClass: 'pack', + screenName: 'available', + parameters: { + 'id': dto.id, + 'title': dto.title, + 'version': dto.version, + }.asFirebaseMap); + } + + static Future buyPackOpened(CardPackBuyDto dto) { + return _analytics.logScreenView( + screenClass: 'pack', + screenName: 'buy', + parameters: { + 'id': dto.id, + 'title': dto.title, + 'version': dto.version, + 'price': dto.price, + }.asFirebaseMap); + } + + static Future buyPackPressed(CardPackBuyDto dto, PaymentSystem system) { + return _analytics.logEvent( + name: 'buy', + parameters: { + 'id': dto.id, + 'title': dto.title, + 'version': dto.version, + 'price': dto.price, + 'system': system.name, + }.asFirebaseMap); + } + + static Future buyError( + CardPackBuyDto? dto, + PaymentSystem system, { + Map? data, + String? message, + }) { + return _analytics.logEvent( + name: 'buy_error', + parameters: { + 'id': dto?.id.toString(), + 'system': system.name, + 'version': dto?.version.toString(), + 'price': dto?.price.toString(), + if (message != null) 'message': message, + if (data != null) ...data, + }.asFirebaseMap); + } + + static Future buyEvent( + CardPackBuyDto? dto, + PaymentSystem system, { + Map? data, + String? message, + }) { + return _analytics.logEvent( + name: 'buy_event', + parameters: { + 'id': dto?.id, + 'system': system.name, + 'version': dto?.version, + 'price': dto?.price, + if (message != null) 'message': message, + if (data != null) ...data, + }.asFirebaseMap); + } + + static Future buyComplete( + CardPackBuyDto? dto, + PaymentSystem system, { + Map? data, + String? message, + }) { + return _analytics.logEvent( + name: 'buy_complete', + parameters: { + 'id': dto?.id, + 'system': system.name, + 'version': dto?.version, + 'price': dto?.price, + if (message != null) 'message': message, + if (data != null) ...data, + }.asFirebaseMap, + ); + } + + static Future loadingPackOpened(CardPackDto dto, int timeMillis) { + return _analytics.logScreenView( + screenClass: 'pack', + screenName: 'loading', + parameters: { + 'id': dto.id, + 'title': dto.title, + 'version': dto.version, + 'time': timeMillis, + }.asFirebaseMap); + } + + static Future testOpened(TestDto dto) { + return _analytics.logScreenView( + screenClass: 'test', + screenName: 'test', + parameters: { + 'id': dto.id, + 'name': dto.name, + 'version': dto.version, + 'questions': dto.questions.length, + }.asFirebaseMap); + } + + static crashlyticsError(Object? e, StackTrace? s) => + _crashlytics.recordError(e, s); + + static error({Map? data, String? message}) { + try { + _analytics.logEvent( + name: 'error', + parameters: { + 'msg': message, + if (data != null) ...data, + }.asFirebaseMap, + ); + } catch (e, s) { + _crashlytics.recordError(e, s); + } + } +} + +extension on Map { + Map get asFirebaseMap => (Map.from(this) + ..removeWhere((k, v) => v == null)) + .map((k, v) => MapEntry(k, (v is String || v is num) ? v : v.toString())); +} diff --git a/lib/features/packs/images_holder.dart b/lib/features/packs/images_holder.dart index e2e6432..93f6ada 100644 --- a/lib/features/packs/images_holder.dart +++ b/lib/features/packs/images_holder.dart @@ -6,21 +6,34 @@ import 'package:mnemo_cards_common/mnemo_cards_common.dart'; class ImagesHolder { Map _images = {}; + Map _packImages = {}; DateTime? unlockTime; ImagesHolder(); void setPackImages(Map images) { - _images = Map.from(images); + _packImages = Map.from(images); } - void clear() => _images.clear(); + void setImage(String key, MemoryImage image) { + _images[key] = image; + } + + void clear() { + _images.clear(); + _packImages.clear(); + } MemoryImage? get(String id) { - final memoryImage = _images[id]; + final memoryImage = getPackImage(id) ?? _images[id]; return memoryImage; } + + MemoryImage? getPackImage(String id) { + final memoryImage = _packImages[id]; + return memoryImage; + } } extension ImagesHolderStringExt on String { @@ -30,7 +43,3 @@ extension ImagesHolderStringExt on String { extension ImagesHolderCardExt on GameCardDto { MemoryImage? get memoryImage => locator.imagesHolder.get(id.toString()); } - -extension ImagesHolderIntExt on int { - MemoryImage? get memoryImage => locator.imagesHolder.get(toString()); -} diff --git a/lib/features/packs/pack_cache_manager.dart b/lib/features/packs/pack_cache_manager.dart index dc7caac..af85a22 100644 --- a/lib/features/packs/pack_cache_manager.dart +++ b/lib/features/packs/pack_cache_manager.dart @@ -120,8 +120,10 @@ class PackCacheManager { await _sharedPreferences.remove('packs'); } - Future savePack(CardPackDto dto, - {Map? images}) async { + Future savePack( + CardPackDto dto, { + Map? images, + }) async { log('Saving pack ${dto.id}'); final dir = await _packDirectory(dto.id); final json = jsonEncode(dto); diff --git a/lib/features/packs/pack_manager.dart b/lib/features/packs/pack_manager.dart index 61c8fae..76c4b9b 100644 --- a/lib/features/packs/pack_manager.dart +++ b/lib/features/packs/pack_manager.dart @@ -1,9 +1,11 @@ import 'dart:async'; import 'dart:developer'; +import 'package:mnemo_cards/di/locator.dart'; import 'package:mnemo_cards/features/packs/pack_cache_manager.dart'; import 'package:mnemo_cards/features/packs/packs_api.dart'; import 'package:mnemo_cards/features/packs/preview_pack_holder.dart'; +import 'package:mnemo_cards/main.dart'; import 'package:mnemo_cards/managers/repository/repository.dart'; import 'package:mnemo_cards/utils/iterable_helper.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart'; @@ -44,24 +46,42 @@ class PackManager { // }); } - Stream getCardPackStream(String id) async* { - try { - yield await _cacheManager.loadPackFromCache(id); - } catch (err) { - log(err.toString()); - log('Cant load pack from cache, fetching pack'); + Stream getCardPackStream( + String id, + bool isAvailable, + ) async* { + if (isAvailable) { try { - final dto = await _packsApi.getPackDto(id).catchError((_) => null); - if (dto != null) { - yield dto; - } else { - final buyDto = await _packsApi.getPackBuy(id); - if (buyDto != null) { - yield buyDto; - } - } + yield await _cacheManager.loadPackFromCache(id); } catch (err) { - log(err.toString()); + yield GetCardPackResponse.loading(); + log('Cant load pack from cache, fetching pack', error: err); + try { + await locator.packUpdater.updatePackAndSaveImages(id); + yield await _cacheManager.loadPackFromCache(id); + } on Object catch (e, s) { + log('Cant fetch pack', error: e, stackTrace: s); + } finally { + log('Updating'); + unawaited(locator.packUpdater.updateAvailablePacks()); + } + } + } else { + yield GetCardPackResponse.loading(); + final buyDto = await _packsApi.getPackBuy(id).catchError((_) => null); + if (buyDto != null) { + yield buyDto; + } else { + log('Buy dto is null, fetching pack'); + try { + await locator.packUpdater.updatePackAndSaveImages(id); + yield await _cacheManager.loadPackFromCache(id); + } on Object catch (e, s) { + log('Cant fetch pack', error: e, stackTrace: s); + } finally { + log('Updating'); + unawaited(locator.packUpdater.updateAvailablePacks()); + } } } yield* _packHolder.asStream @@ -73,6 +93,7 @@ class PackManager { Future clearCache() async { _packHolder.clear(); await _cacheManager.clearCache(); + await globalSharedPreferences.clear(); } Stream?> get packsStream => diff --git a/lib/features/packs/pack_updater.dart b/lib/features/packs/pack_updater.dart index a44b86a..2a5bbee 100644 --- a/lib/features/packs/pack_updater.dart +++ b/lib/features/packs/pack_updater.dart @@ -34,7 +34,7 @@ class PackUpdater { await updateAvailablePacks(); try { final r = await _packsApi.getPacksPreviews(null); - _previewPackHolder.setPreviewPacks(r); + _previewPackHolder.setPreviewPacks(r, save: true); } catch (e) { log(e.toString()); } @@ -79,7 +79,7 @@ class PackUpdater { log('UPDATE ${action.id}'); shouldUpdateHolder = true; try { - final updatedPack = await _updatePackAndSaveImages(action.id); + final updatedPack = await updatePackAndSaveImages(action.id); updatedPacks[action.id] = updatedPack; } catch (e) { log('Error while download cards for ${action.id} $e'); @@ -117,7 +117,7 @@ class PackUpdater { return false; } - Future _updatePackAndSaveImages(String packId) async { + Future updatePackAndSaveImages(String packId) async { try { log('Updating with cards ${packId}', name: 'updatePackWithCards'); final packDto = await _packsApi.getPackDto(packId); diff --git a/lib/features/packs/preview_pack_holder.dart b/lib/features/packs/preview_pack_holder.dart index 635d25f..3d3f26e 100644 --- a/lib/features/packs/preview_pack_holder.dart +++ b/lib/features/packs/preview_pack_holder.dart @@ -1,7 +1,12 @@ import 'dart:async'; +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'; + +import '../../main.dart'; class PreviewPackHolder { final StreamController _streamController = @@ -10,8 +15,52 @@ class PreviewPackHolder { PreviewPackHolder(); - void setPreviewPacks(List packs) { + Future init() async { + if (_state == null) { + await loadFromCache(); + } + } + + Future loadFromCache() async { + final sp = globalSharedPreferences; + final previewPacksStrings = sp.getStringList('preview_packs'); + if (previewPacksStrings == null || previewPacksStrings.isEmpty) { + return; + } + final List previewPacks = []; + previewPacksStrings.forEach((string) { + try { + final pack = CardPackPreviewDto.fromJson( + jsonDecode(string) as Map, + ); + previewPacks.add(pack); + } catch (e, s) { + log('Cant load preview pack', error: e, stackTrace: s); + } + }); + setPreviewPacks(previewPacks); + } + + Future savePreviewPacks() async { + final packs = state?.previewPacks ?? []; + final stringsList = []; + packs.forEach((pack) { + try { + final string = jsonEncode(pack); + stringsList.add(string); + } catch (e, s) { + log('Cant load preview pack', error: e, stackTrace: s); + } + }); + final sp = globalSharedPreferences; + sp.setStringList('preview_packs', stringsList); + } + + void setPreviewPacks(List packs, {bool save = false}) { _state = PreviewPackState(packs); + if (save) { + savePreviewPacks(); + } _streamController.add(_state); } diff --git a/lib/features/packs/preview_packs_poller.dart b/lib/features/packs/preview_packs_poller.dart index cb35265..1ab408d 100644 --- a/lib/features/packs/preview_packs_poller.dart +++ b/lib/features/packs/preview_packs_poller.dart @@ -22,7 +22,7 @@ class PreviewPacksPoller { try { polling = true; final r = await _api.getPacksPreviews(null); - _previewPackHolder.setPreviewPacks(r); + _previewPackHolder.setPreviewPacks(r, save: true); } catch (e) { log(e.toString()); } diff --git a/lib/features/purchase/in_app_purchase.dart b/lib/features/purchase/in_app_purchase.dart index 9b44660..7dd46de 100644 --- a/lib/features/purchase/in_app_purchase.dart +++ b/lib/features/purchase/in_app_purchase.dart @@ -10,6 +10,7 @@ import 'package:webview_flutter/webview_flutter.dart'; import '../../di/injector.dart'; import '../../di/locator.dart'; import '../../main.dart'; +import '../analytics/analytics.dart'; enum StoreItemType { ads, @@ -25,15 +26,34 @@ class InAppPurchaseService { InAppPurchase get instance => _inAppPurchase; Future buyPack(CardPackBuyDto dto) async { - final pack = await getPackStoreProduct(dto); - final result = await buyItemInStore(pack.first); - return result; + try { + final pack = await getPackStoreProduct(dto); + final result = await buyItemInStore(pack.first); + return result; + } catch (e, s) { + log('err', error: e, stackTrace: s); + Analytics.buyError( + dto, + PaymentSystem.google, + data: { + 'e': e.toString(), + 's': s.toString(), + }, + ); + return false; + } } - Future buyPackWithYooMoney(CardPackBuyDto dto, BuildContext? context) async { + Future buyPackWithYooMoney( + CardPackBuyDto dto, BuildContext? context) async { final paymentUrl = await locator.repository.createPayment(dto.id, PaymentSystem.yookassa); if (paymentUrl == null) { + Analytics.buyError( + dto, + PaymentSystem.yookassa, + message: 'payment url is null', + ); return false; } final controller = WebViewController() @@ -57,9 +77,18 @@ class InAppPurchaseService { ).then((_) async { final success = await locator.repository.checkUserPayment(); if (success) { + Analytics.buyComplete( + dto, + PaymentSystem.yookassa, + ); locator.packUpdater.updateAvailablePacks(); return true; } + Analytics.buyError( + dto, + PaymentSystem.yookassa, + message: 'payment not verified', + ); return false; }); } @@ -67,6 +96,11 @@ class InAppPurchaseService { Future> getPackStoreProduct(CardPackBuyDto dto) async { final bool isAvailable = await _inAppPurchase.isAvailable(); if (!isAvailable) { + Analytics.buyError( + dto, + PaymentSystem.google, + message: 'in app purchase not available', + ); return []; } final ids = { @@ -74,6 +108,11 @@ class InAppPurchaseService { if (Platform.isIOS) dto.appStoreId, }.whereNotNull().toSet(); if (ids.isEmpty) { + Analytics.buyError( + dto, + PaymentSystem.google, + message: 'product id not set', + ); return []; } @@ -82,6 +121,14 @@ class InAppPurchaseService { if (productDetailResponse.error != null || productDetailResponse.productDetails.isEmpty) { + Analytics.buyError( + dto, + PaymentSystem.google, + data: { + 'msg': 'cant get product details ${productDetailResponse.error}', + 'not_found': productDetailResponse.notFoundIDs.join(','), + }, + ); return []; } @@ -104,14 +151,34 @@ class PurchaseDetailsStreamSubscription { Future onPurchased(PurchaseDetails purchaseDetails) async { try { + Analytics.buyEvent( + null, + PaymentSystem.google, + message: 'onPurchased', + data: { + 'status': purchaseDetails.status.name, + 'product': purchaseDetails.productID, + 'date': purchaseDetails.transactionDate, + }, + ); final result = await locator.repository.checkPayment( purchaseDetails.productID, purchaseDetails.verificationData.serverVerificationData, PaymentSystem.google, ); if (result) { + Analytics.buyComplete( + null, + PaymentSystem.google, + message: purchaseDetails.productID, + ); log('Purchased'); } else { + Analytics.buyError( + null, + PaymentSystem.yookassa, + message: 'payment not verified ${purchaseDetails.productID}', + ); throw Exception('Cant verify purchase'); } } catch (e, s) { 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 ff05b5d..6cde7d4 100644 --- a/lib/features/tests/question_states/input_buttons_test_state.dart +++ b/lib/features/tests/question_states/input_buttons_test_state.dart @@ -11,7 +11,7 @@ part 'input_buttons_test_state.g.dart'; @CopyWith() class InputButtonsTestState extends TestQuestionState { final List answer; - final List answerIndexes; + final List answerIds; final bool isCorrect; final bool isAnswered; final int seed; @@ -19,7 +19,7 @@ class InputButtonsTestState extends TestQuestionState { InputButtonsTestState({ required this.seed, this.answer = const [], - this.answerIndexes = const [], + this.answerIds = const [], this.isCorrect = false, this.isAnswered = false, super.testType = TestQuestionType.simple, diff --git a/lib/features/tests/question_states/input_buttons_test_state.g.dart b/lib/features/tests/question_states/input_buttons_test_state.g.dart index 6311a53..ae9cd54 100644 --- a/lib/features/tests/question_states/input_buttons_test_state.g.dart +++ b/lib/features/tests/question_states/input_buttons_test_state.g.dart @@ -11,7 +11,7 @@ abstract class _$InputButtonsTestStateCWProxy { InputButtonsTestState answer(List answer); - InputButtonsTestState answerIndexes(List answerIndexes); + InputButtonsTestState answerIds(List answerIds); InputButtonsTestState isCorrect(bool isCorrect); @@ -28,7 +28,7 @@ abstract class _$InputButtonsTestStateCWProxy { InputButtonsTestState call({ int? seed, List? answer, - List? answerIndexes, + List? answerIds, bool? isCorrect, bool? isAnswered, TestQuestionType? testType, @@ -49,8 +49,8 @@ class _$InputButtonsTestStateCWProxyImpl InputButtonsTestState answer(List answer) => this(answer: answer); @override - InputButtonsTestState answerIndexes(List answerIndexes) => - this(answerIndexes: answerIndexes); + InputButtonsTestState answerIds(List answerIds) => + this(answerIds: answerIds); @override InputButtonsTestState isCorrect(bool isCorrect) => this(isCorrect: isCorrect); @@ -74,7 +74,7 @@ class _$InputButtonsTestStateCWProxyImpl InputButtonsTestState call({ Object? seed = const $CopyWithPlaceholder(), Object? answer = const $CopyWithPlaceholder(), - Object? answerIndexes = const $CopyWithPlaceholder(), + Object? answerIds = const $CopyWithPlaceholder(), Object? isCorrect = const $CopyWithPlaceholder(), Object? isAnswered = const $CopyWithPlaceholder(), Object? testType = const $CopyWithPlaceholder(), @@ -88,11 +88,10 @@ class _$InputButtonsTestStateCWProxyImpl ? _value.answer // ignore: cast_nullable_to_non_nullable : answer as List, - answerIndexes: - answerIndexes == const $CopyWithPlaceholder() || answerIndexes == null - ? _value.answerIndexes - // ignore: cast_nullable_to_non_nullable - : answerIndexes as List, + answerIds: answerIds == const $CopyWithPlaceholder() || answerIds == null + ? _value.answerIds + // ignore: cast_nullable_to_non_nullable + : answerIds as List, isCorrect: isCorrect == const $CopyWithPlaceholder() || isCorrect == null ? _value.isCorrect // ignore: cast_nullable_to_non_nullable @@ -129,8 +128,8 @@ InputButtonsTestState _$InputButtonsTestStateFromJson( ?.map((e) => e as String) .toList() ?? const [], - answerIndexes: (json['answerIndexes'] as List?) - ?.map((e) => (e as num).toInt()) + answerIds: (json['answerIds'] as List?) + ?.map((e) => e as String) .toList() ?? const [], isCorrect: json['isCorrect'] as bool? ?? false, @@ -145,7 +144,7 @@ Map _$InputButtonsTestStateToJson( { 'testType': _$TestQuestionTypeEnumMap[instance.testType]!, 'answer': instance.answer, - 'answerIndexes': instance.answerIndexes, + 'answerIds': instance.answerIds, 'isCorrect': instance.isCorrect, 'isAnswered': instance.isAnswered, 'seed': instance.seed, diff --git a/lib/features/tests/test_manager.dart b/lib/features/tests/test_manager.dart index 0229193..d195306 100644 --- a/lib/features/tests/test_manager.dart +++ b/lib/features/tests/test_manager.dart @@ -5,10 +5,12 @@ import 'package:flutter/widgets.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'; +import 'package:mnemo_cards/main.dart'; import 'package:mnemo_cards/theme/themes.dart'; import 'package:mnemo_cards/utils/iterable_helper.dart'; import 'package:mnemo_cards/utils/string_helper.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart'; +import 'package:rxdart/rxdart.dart'; import '../../managers/repository/http_repository.dart'; import 'question_states/test_question_state.dart'; @@ -28,6 +30,18 @@ class TestManager extends ChangeNotifier { return tests; } + Stream> loadTestsStream({String? packId}) { + return Stream.periodic( + Duration(seconds: 60), + ) + .startWith(null) + .asyncMap( + (tests) async => loadTests(packId: packId) + .catchError((_) => Future.value([])), + ) + .where((test) => test.isNotEmpty); + } + TestQuestionState? getState(int id) => testHolder.testQuestionState(id); Iterable get states => testHolder.state.questions.values; @@ -37,7 +51,15 @@ class TestManager extends ChangeNotifier { if (packId != null) { await packCacheManager.loadPackFromCache(packId); } - final test = await _repository.getTest(id); + TestDto? test; + try { + test = await _repository.getTest(id); + } on Object catch (e, s) { + log('error when loading test ${id}'); + } + if (test == null) { + appRouter.maybePop(); + } questions.clear(); questions.addAll(test!.questions); color = test.color?.asColor ?? borderGray; diff --git a/lib/features/tests/test_page.dart b/lib/features/tests/test_page.dart index b9b888d..c032652 100644 --- a/lib/features/tests/test_page.dart +++ b/lib/features/tests/test_page.dart @@ -38,7 +38,7 @@ class _TestPageState extends State { if (!s.hasData || s.requireData!.questions.isEmpty) { return Scaffold( body: Center( - child: Text('Loading'), + child: Text('Загружаю тест...'), ), ); } diff --git a/lib/features/tests/test_widgets/input_buttons_test.dart b/lib/features/tests/test_widgets/input_buttons_test.dart index d27f84a..251af07 100644 --- a/lib/features/tests/test_widgets/input_buttons_test.dart +++ b/lib/features/tests/test_widgets/input_buttons_test.dart @@ -1,129 +1,101 @@ import 'dart:math'; +import 'package:collection/collection.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.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/theme/themes.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart'; - +import 'package:collection/collection.dart'; import '../../../di/locator.dart'; import '../../../widgets/game_card_widget.dart'; import '../question_states/input_buttons_test_state.dart'; import 'test_button.dart'; class InputButtonsTestWidget extends StatelessWidget { - final InputButtonsTestQuestionBody model; - bool get isCorrect => state.answer.join() == model.answer; + final InputButtonsTestQuestionBody _inputButtonModel; + + InputButtonsTestQuestionBody get model => _inputButtonModel; InputButtonsTestState get state => manager.getState(model.id!) as InputButtonsTestState; TestManager get manager => locator.testManager; - InputButtonsTestWidget(this.model, {super.key}); + Color get testColor => manager.color; - Widget _letter(BuildContext context, String text) => Padding( - padding: const EdgeInsets.all(4.0), - child: Material( - child: Container( - padding: const EdgeInsets.all(8.0), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8.0), - boxShadow: const [ - BoxShadow( - color: Colors.black26, - blurRadius: 4.0, - ), - ], - ), - child: Text( - text, - style: Theme.of(context).textTheme.titleLarge, - ), - ), - ), - ); - - Widget _emptyCell(BuildContext context) => Padding( - padding: const EdgeInsets.all(4.0), - child: Material( - child: Container( - padding: const EdgeInsets.all(8.0), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8.0), - boxShadow: const [ - BoxShadow( - color: Colors.black26, - blurRadius: 4.0, - ), - ], - ), - child: Text( - ' ', - style: Theme.of(context).textTheme.titleLarge, - ), - ), - ), - ); + InputButtonsTestWidget(this._inputButtonModel, {super.key}); @override Widget build(BuildContext context) { final theme = Theme.of(context).textTheme; - final random = Random(state.seed); - final buttons = model.buttons..shuffle(random); - return LayoutBuilder(builder: (context, constraints) { - return Padding( - padding: const EdgeInsets.all(8.0), - child: Column( + // final random = Random(state.seed); + final buttons = model.buttons; //..shuffle(random); + return Padding( + padding: const EdgeInsets.all(8.0), + child: LayoutBuilder(builder: (context, constraints) { + return Column( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Flexible( - flex: 2, - child: AspectRatio( - aspectRatio: 1, - child: Container( - margin: const EdgeInsets.all(4.0), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: - BorderRadius.circular(GameCardWidget.borderRadius), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (model.image != null) - TestImageWidget( - TestImage.image(model.image!), + if (model.image != null) + Container( + margin: const EdgeInsets.all(4.0), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: + BorderRadius.circular(16.0), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AspectRatio( + aspectRatio: 1, + child: TestImageWidget( + TestImage.image(model.image!), + ), + ), + if (model.text != null) + Container( + padding: EdgeInsets.only(top: 8.0), + child: Center( + child: Text( + model.text!, + style: theme.headlineLarge, ), - if (model.text != null) - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Center( - child: Text( - model.text!, - style: theme.headlineLarge, - ), - ), - ), - ], - ), + ), + ), + ], + ), + ) + else Flexible( + child: Center( + child: Text( + model.text!, + style: theme.headlineLarge, ), ), ), StatefulBuilder(builder: (context, setState) { - void buttonTapped((int, TestButtonDto) p) { + void buttonTapped(int index, TestButtonDto button) { if (state.answer.fold(0, (p, n) => p + n.length) >= model.answer.length) { return; } setState(() { + var answer = [...state.answer, button.text!]; + final ignoreSpaces = true; + if (ignoreSpaces && + model.answer.length > answer.length && + model.answer[answer.length] == ' ') { + answer.add(' '); + } manager.setState( model.id!, state.copyWith( - answer: [...state.answer, p.$2.text!], + answer: answer, + answerIds: [...state.answerIds, button.id], ), ); manager.setState( @@ -143,35 +115,91 @@ class InputButtonsTestWidget extends StatelessWidget { } } + int splitIndex = 0; + if (model.answer.length * 30 > constraints.maxWidth * 0.9) { + splitIndex = model.answer.indexOf( + ' ', + (model.answer.length / 2).ceil(), + ); + if (splitIndex < 10) { + splitIndex = model.answer.indexOf( + ' ', + (model.answer.length / 3).ceil(), + ); + } + if (splitIndex == -1) { + splitIndex = model.answer.indexOf( + ' ', + 0, + ); + } + } + if (splitIndex < 0) { + splitIndex = 0; + } + + final letters = model.answer + .split('') + .mapIndexed( + (index, letter) => letter == ' ' + ? SizedBox(width: 10) + : _Letter( + text: state.answer.length > index + ? state.answer[index] + : ' ', + color: testColor, + ), + ) + .toList(); + final rows = [ + if (splitIndex > 0) + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: letters.sublist(0, splitIndex), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: letters.sublist(splitIndex), + ) + ]; + return Expanded( child: Column( children: [ Expanded( child: GestureDetector( onTap: () { + var answer = state.answer.toList(); + var answerIds = state.answerIds.toList(); + if (answer.isNotEmpty) { + if (answer.last == ' ') { + answer.removeLast(); + } + if (answer.isNotEmpty) { + answer.removeLast(); + answerIds.removeLast(); + } + } setState(() { manager.setState( model.id!, state.copyWith( - isAnswered: false, - isCorrect: false, - answer: [], - answerIndexes: [], + // isAnswered: false, + // isCorrect: false, + answer: answer, + answerIds: answerIds, ), ); }); }, - child: Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, - children: [ - ...state.answer.map((e) => _letter(context, e)), - ...model.answer - .substring( - state.answer.fold(0, (p, n) => p + n.length), - ) - .characters - .map((e) => _emptyCell(context)), - ], + mainAxisSize: MainAxisSize.min, + children: rows, ), ), ), @@ -181,22 +209,25 @@ class InputButtonsTestWidget extends StatelessWidget { AbsorbPointer( absorbing: state.isCorrect, child: Wrap( - spacing: 8.0, - runSpacing: 8.0, children: [ - ...buttons.indexed.map( - (p) => Container( - width: p.$2.text!.length > 3 ? null : 80, - height: 60, - child: TestButton( - text: p.$2.text, - onTap: () => buttonTapped(p), - color: state.answer == p.$2 - ? isCorrect - ? Colors.green[200] - : Colors.red[200] - : Colors.white, - ), + ...buttons.mapIndexed( + (index, button) => Container( + margin: EdgeInsets.all(4.0), + width: button.text!.length > 3 + ? null + : constraints.maxWidth / 6, + height: 50, + child: state.answerIds.contains(button.id) + ? SizedBox.shrink() + : TestButton( + text: button.text, + onTap: () => buttonTapped(index, button), + color: state.answer == button + ? isCorrect + ? Colors.green[200] + : Colors.red[200] + : Colors.white, + ), ), ), ], @@ -207,8 +238,45 @@ class InputButtonsTestWidget extends StatelessWidget { ); }) ], - ), - ); - }); + ); + }), + ); + } +} + +class _Letter extends StatelessWidget { + final String text; + final Color? color; + + _Letter({this.text = '', this.color}); + + @override + Widget build(BuildContext context) { + return Container( + width: 30, + height: 45, + margin: const EdgeInsets.all(2.0), + padding: const EdgeInsets.all(5.0), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(6.0), + border: Border.all(color: color ?? borderGray), + boxShadow: const [ + // BoxShadow( + // color: Colors.black26, + // blurRadius: 4.0, + // ), + ], + ), + child: Material( + color: Colors.white, + child: Text( + text, + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + ), + ); } } diff --git a/lib/flags.dart b/lib/flags.dart index 45e5568..3232754 100644 --- a/lib/flags.dart +++ b/lib/flags.dart @@ -1,3 +1,2 @@ -import 'package:flutter/foundation.dart'; -const ADMIN_BUILD = true; \ No newline at end of file +const ADMIN_BUILD = false; \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 33bb2c0..88d0262 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:developer'; import 'dart:io'; @@ -5,7 +6,9 @@ import 'package:auto_route/annotations.dart'; import 'package:auto_route/auto_route.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:jailbreak_root_detection/jailbreak_root_detection.dart'; import 'package:mnemo_cards/di/locator.dart'; @@ -26,7 +29,7 @@ import 'managers/repository/repository.dart'; final scaffoldKey = GlobalKey(); final scaffoldMessengerKey = GlobalKey(); -SharedPreferences? globalSharedPreferences; +late final SharedPreferences globalSharedPreferences; final appRouter = AppRouter(ADMIN_BUILD); @@ -37,28 +40,49 @@ Future _screenOff() async => void main() async { WidgetsFlutterBinding.ensureInitialized(); + final stopwatch = Stopwatch()..start(); + print('ensureInitialized ${stopwatch.elapsedMilliseconds}'); + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + ]); androidDeviceInfo = await DeviceInfoPlugin().androidInfo; + print('deviceInfo ${stopwatch.elapsedMilliseconds}'); final isNotTrust = await JailbreakRootDetection.instance.isNotTrust; + print('No trunst ${stopwatch.elapsedMilliseconds}'); if (isNotTrust && !ADMIN_BUILD) { print('Not trusted'); return; } final off = await _screenOff(); + print('screenshot off ${stopwatch.elapsedMilliseconds}'); if (!off) { print('Cant disable screenhsot'); return; } + + FlutterError.onError = (errorDetails) { + FirebaseCrashlytics.instance.recordFlutterFatalError(errorDetails); + }; + try { globalSharedPreferences = await SharedPreferences.getInstance(); + print('global sp ${stopwatch.elapsedMilliseconds}'); await setInjections(); - await initFirebase(); + print('injections ${stopwatch.elapsedMilliseconds}'); + unawaited(initFirebase()); + print('initing... ${stopwatch.elapsedMilliseconds}'); await locator.packCacheManager.init(); + await locator.previewPackHolder.init(); + await locator.previewPackPoller.init(); await locator.packManager.init(); await locator.userManager.init(); await locator.favoriteCardsController.init(); await locator.previewPackPoller.init(); await locator.packUpdater.init(); + print('initing complete ${stopwatch.elapsedMilliseconds}'); await MobileAds.initialize(); + print('mobile ads ${stopwatch.elapsedMilliseconds}'); + print('runApp ${stopwatch.elapsedMilliseconds}'); runApp(const MyApp()); } on Object catch (e, s) { log(e.toString(), stackTrace: s); @@ -70,7 +94,7 @@ Future initFirebase() async { try { await Firebase.initializeApp( options: DefaultFirebaseOptions.currentPlatform, - ).timeout(Duration(seconds: 2)); + ); await FirebaseRepository.update(); } on Object catch (e, s) { log('Firebase not inited', error: e, stackTrace: s); diff --git a/lib/managers/favorite_cards.dart b/lib/managers/favorite_cards.dart index 4145276..25fa0fc 100644 --- a/lib/managers/favorite_cards.dart +++ b/lib/managers/favorite_cards.dart @@ -22,7 +22,7 @@ class FavoriteCardsController { } List get state { - state = (_sharedPreferences.getStringList('favorites') ?? []); + _state = (_sharedPreferences.getStringList('favorites') ?? []); return _state; } diff --git a/lib/managers/repository/api.dart b/lib/managers/repository/api.dart index 014b583..290fce2 100644 --- a/lib/managers/repository/api.dart +++ b/lib/managers/repository/api.dart @@ -1,12 +1,13 @@ import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; +import 'package:mnemo_cards/flags.dart'; import '../../main.dart'; import 'firebase_config_repository.dart'; mixin Api { String get path => - kReleaseMode || (globalSharedPreferences?.getBool('env') ?? true) + !ADMIN_BUILD || (globalSharedPreferences.getBool('env') ?? true) //prod ? 'https://89.19.210.178:8080' //FirebaseRepository.appConfig.path : androidDeviceInfo!.isPhysicalDevice diff --git a/lib/managers/user_manager.dart b/lib/managers/user_manager.dart index 985a77f..0aed260 100644 --- a/lib/managers/user_manager.dart +++ b/lib/managers/user_manager.dart @@ -15,6 +15,7 @@ import 'package:device_info_plus/device_info_plus.dart'; import '../domain/router/app_router.dart'; import '../domain/router/app_router.gr.dart'; +import '../features/analytics/analytics.dart'; import 'user_state_holder.dart'; class UserManager { @@ -48,6 +49,7 @@ class UserManager { if (await _googleSignIn.isSignedIn()) { await _googleSignIn.signOut(); } + Analytics.logout(userStateHolder.user); userStateHolder.clear(); } @@ -56,9 +58,10 @@ class UserManager { ExternalIdType idType, String? name, ) async { - final p = await _repository.createOrGetUser(externalId, idType, name); - _setAuthToken(p.$2); - userStateHolder.setUser(p.$1); + final (user, token) = + await _repository.createOrGetUser(externalId, idType, name); + _setAuthToken(token); + userStateHolder.setUser(user); } void _setAuthToken(String authToken) async { @@ -111,6 +114,7 @@ class UserManager { final name = account.displayName; final token = await account.authentication.then((value) => value.idToken); + Analytics.login(ExternalIdType.google); await _createUser(token!, ExternalIdType.google, name); } })) diff --git a/lib/managers/user_state_holder.dart b/lib/managers/user_state_holder.dart index 3531795..777d1bd 100644 --- a/lib/managers/user_state_holder.dart +++ b/lib/managers/user_state_holder.dart @@ -4,6 +4,8 @@ import 'package:flutter/widgets.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:rxdart/rxdart.dart'; +import '../features/analytics/analytics.dart'; + class UserStateHolder extends ChangeNotifier { UserDto? _dto; @@ -17,6 +19,8 @@ class UserStateHolder extends ChangeNotifier { void setUser(UserDto dto) { _dto = dto.copyWith(); + Analytics.setUser(dto); + notifyListeners(); _streamController.add(_dto); } diff --git a/lib/pages/profile_page.dart b/lib/pages/profile_page.dart index 4348ed1..9367f69 100644 --- a/lib/pages/profile_page.dart +++ b/lib/pages/profile_page.dart @@ -13,6 +13,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../admin/all_cards.dart'; import '../di/locator.dart'; +import '../features/analytics/analytics.dart'; import '../theme/themes.dart'; import '../widgets/shared_pref_button.dart'; import '../widgets/text_button.dart'; @@ -21,6 +22,7 @@ import '../widgets/text_button.dart'; class ProfilePage extends StatelessWidget { @override Widget build(BuildContext context) { + Analytics.profilePageOpened(locator.userManager.userStateHolder.user); final theme = Theme.of(context).textTheme; return Scaffold( body: SafeArea( @@ -30,7 +32,7 @@ class ProfilePage extends StatelessWidget { stream: locator.userManager.userStateHolder.asStream, builder: (context, snapshot) { var title = snapshot.data?.name; - if (title == null || title.length > 10) { + if (true || title == null || title.length > 10) { title = 'Привет!'; } return Header( @@ -63,7 +65,8 @@ class ProfilePage extends StatelessWidget { if (ADMIN_BUILD) MaterialButton( onPressed: () { - showDialog(context: context, builder: (c) => const AllCards()); + showDialog( + context: context, builder: (c) => const AllCards()); }, child: Text('ALL CARDS'), ), diff --git a/lib/widgets/card_game/card_game_main.dart b/lib/widgets/card_game/card_game_main.dart index 8e32187..8d1759c 100644 --- a/lib/widgets/card_game/card_game_main.dart +++ b/lib/widgets/card_game/card_game_main.dart @@ -1,19 +1,12 @@ import 'dart:math'; import 'package:auto_route/auto_route.dart'; -import 'package:dio/dio.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:mnemo_cards/features/packs/images_holder.dart'; -import 'package:mnemo_cards/flags.dart'; import 'package:mnemo_cards/utils/color_helper.dart'; -import 'package:mnemo_cards/utils/string_helper.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:rxdart/rxdart.dart'; -import '../../admin/add_card.dart'; -import '../../di/locator.dart'; -import '../../domain/router/app_router.dart'; import '../../features/card_flipper/flip_card_controllers.dart'; import '../../features/card_swiper/src/allowed_swipe_direction.dart'; import '../../features/card_swiper/src/card_swiper.dart'; @@ -22,14 +15,16 @@ import '../../main.dart'; import '../game_card_widget.dart'; class CardGameMainWidget extends StatelessWidget { - final CardPackDto cardPack; + final List cards; + final Color? color; final int? startCardId; final CardSwiperController cardSwiperController; final FlipCardController flipCardController; final int length; const CardGameMainWidget( - this.cardPack, + this.cards, + this.color, this.startCardId, this.cardSwiperController, this.flipCardController, @@ -38,23 +33,9 @@ class CardGameMainWidget extends StatelessWidget { @override Widget build(BuildContext context) { - final numberOfCardsDisplayed = min(2, cardPack.cards.length); + final numberOfCardsDisplayed = min(2, cards.length); final initialScale = 0.9; - int lastThreshold = 0; - List cards = cardPack.cards; - if (startCardId != null) { - final startCardIndex = cards.indexWhere( - (card) => card.id == startCardId, - ); - if (startCardIndex >= 0) { - cards = [ - ...cards.sublist(startCardIndex), - ...cards.sublist(0, startCardIndex), - ]; - } - } else { - cards = cards.toList()..shuffle(); - } + GameCardDto? activeCard() => cardSwiperController.currentIndex >= 0 ? cards[cardSwiperController.currentIndex % cards.length] : null; @@ -75,12 +56,7 @@ class CardGameMainWidget extends StatelessWidget { return StatefulBuilder(builder: (context, setState) { return Stack( children: [ - _EndWidget(cardSwiperController, flipCardController, () { - setState(() { - enabled = false; - }); - AutoRouter.of(context).maybePop(); - }), + _EndWidget(), if (enabled) Hero( tag: activeCard()?.id ?? 'empty', @@ -127,21 +103,21 @@ class CardGameMainWidget extends StatelessWidget { cardIndex); if (cardSwiperController.currentIndex == index) { - lastThreshold = percentThresholdX; + // lastThreshold = percentThresholdX; } print('${cards[index].translation} $scale'); final cardWidget = Center( child: GameCardWidget( cards[index], - cards[index].id.memoryImage, + cards[index].memoryImage, flipCardController, flipCardController.state // || cards[index].isAd , isFirstCard, isFirstCard - ? cardPack.color?.asColor - : cardPack.color?.asColor?.darken( + ? color + : color?.darken( (scale - 1) / (initialScale - 1) * 0.075, @@ -164,38 +140,10 @@ class CardGameMainWidget extends StatelessWidget { } class _EndWidget extends StatelessWidget { - final CardSwiperController cardSwiperController; - final FlipCardController flipCardController; - final VoidCallback callback; - - _EndWidget( - this.cardSwiperController, - this.flipCardController, - this.callback, - ); + _EndWidget(); @override Widget build(BuildContext context) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('Ok: ${cardSwiperController.swipedLeftIds.length}'), - Text('Repeat: ${cardSwiperController.swipedRightIds.length}'), - MaterialButton( - onPressed: () { - cardSwiperController.restart(); - }, - child: Text('restart'), - ), - MaterialButton( - onPressed: () { - callback(); - }, - child: Text('to pack'), - ), - ], - ), - ); + return Container(); } } diff --git a/lib/widgets/card_game/card_game_page.dart b/lib/widgets/card_game/card_game_page.dart index 3861ae6..6887a6f 100644 --- a/lib/widgets/card_game/card_game_page.dart +++ b/lib/widgets/card_game/card_game_page.dart @@ -1,18 +1,13 @@ import 'dart:math'; import 'package:auto_route/auto_route.dart'; -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:mnemo_cards/features/tests/progress_widget.dart'; -import 'package:mnemo_cards/main.dart'; import 'package:mnemo_cards/theme/themes.dart'; -import 'package:mnemo_cards/utils/string_helper.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:rxdart/rxdart.dart'; import '../../di/locator.dart'; -import '../../features/card_flipper/flip_card.dart'; import '../../features/card_flipper/flip_card_controllers.dart'; import '../../features/card_swiper/flutter_card_swiper.dart'; import 'card_game_main.dart'; @@ -20,13 +15,17 @@ import 'card_game_main.dart'; @RoutePage() class CardGamePage extends StatefulWidget { final int? startCardId; - final CardPackDto cardPack; + final List cards; + final Color? color; final bool infinite; + final bool shuffle; const CardGamePage({ - required this.cardPack, + required this.cards, + required this.color, this.startCardId, this.infinite = false, + this.shuffle = true, super.key, }); @@ -38,17 +37,17 @@ class _CardGamePageState extends State { final CardSwiperController cardSwiperController = CardSwiperController(); final FlipCardController flipCardController = FlipCardController(); + List shuffledCards = []; + GameCardDto? get activeCard { final index = cardSwiperController.currentIndex; - final cards = widget.cardPack.cards; if (index >= 0) { - return cards[index % cards.length]; + return shuffledCards[index % shuffledCards.length]; } return null; } - int get gameLength => - widget.infinite ? 10000000 : widget.cardPack.cards.length; + int get gameLength => widget.infinite ? 10000000 : shuffledCards.length; @override Widget build(BuildContext context) { @@ -95,9 +94,8 @@ class _CardGamePageState extends State { cardSwiperController.progress, Result.correct, ), - widget.cardPack.cards.length, - widget.cardPack.color?.asColor ?? - borderGray, + shuffledCards.length, + widget.color ?? borderGray, timer: timer, popText: widget.infinite ? 'к теме' : null, key: ValueKey('CARDS PROGRESS'), @@ -116,7 +114,8 @@ class _CardGamePageState extends State { child: Stack( children: [ CardGameMainWidget( - widget.cardPack, + shuffledCards, + widget.color, widget.startCardId, cardSwiperController, flipCardController, @@ -186,7 +185,12 @@ class _CardGamePageState extends State { ), padding: EdgeInsets.all(10.0.w), child: Image.asset( - "icons/heart.png", + locator.favoriteCardsController + .isFavoriteCard( + activeCard?.id, + ) + ? "icons/heart_fill.png" + : "icons/heart.png", width: 29.w, height: 29.h, color: locator @@ -249,6 +253,31 @@ class _CardGamePageState extends State { ); } + @override + void initState() { + super.initState(); + shuffledCards = widget.cards.toList(); + if (widget.shuffle) { + shuffledCards..shuffle(); + } + if (widget.startCardId != null) { + final startCardIndex = shuffledCards.indexWhere( + (card) => card.id == widget.startCardId, + ); + + if (startCardIndex >= 0) { + shuffledCards = [ + ...shuffledCards.sublist(startCardIndex), + ...shuffledCards.sublist(0, startCardIndex), + ]; + } else { + shuffledCards = []; + } + } else { + shuffledCards = widget.cards.toList(); + } + } + @override void dispose() { // cardSwiperController.removeListener(cardSwiperListener); diff --git a/lib/widgets/card_pack/available_pack.dart b/lib/widgets/card_pack/available_pack.dart index 2e3a95e..0b07f3b 100644 --- a/lib/widgets/card_pack/available_pack.dart +++ b/lib/widgets/card_pack/available_pack.dart @@ -1,16 +1,20 @@ +import 'dart:async'; import 'dart:math'; +import 'package:collection/collection.dart'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/animation.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:mnemo_cards/features/packs/images_holder.dart'; import 'package:mnemo_cards/main.dart'; -import 'package:mnemo_cards/utils/iterable_helper.dart'; +import 'package:mnemo_cards/widgets/fade_in_widget.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:yandex_mobileads/mobile_ads.dart'; import '../../domain/router/app_router.gr.dart'; +import '../../features/analytics/analytics.dart'; import '../../features/yandex_ads/yandex_ads.dart'; import '../game_card_widget.dart'; @@ -19,67 +23,113 @@ import '../../theme/themes.dart'; import 'card_pack_header.dart'; import 'package:mnemo_cards/utils/string_helper.dart'; +import 'cards_view.dart'; +import 'cards_view_controller.dart'; + +final _mainScrollController = ScrollController(); + class AvailablePack extends StatelessWidget { final CardPackDto cardPack; - final _CardsViewController _cardsViewController = _CardsViewController(); + late final CardsViewController _cardsViewController = + CardsViewController(cardPack); AvailablePack(this.cardPack); @override Widget build(BuildContext context) { + Analytics.availablePackOpened(cardPack); return Column( children: [ CardPackHeader.fromDto(cardPack), Expanded( child: ListView( + controller: _mainScrollController, children: [ StatefulBuilder(builder: (context, setState) { return Column( children: [ Container( - padding: EdgeInsets.symmetric(vertical: 0.h), decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: cardPack.color?.asColor ?? - Colors.grey.withOpacity(0.5)), - ), + color: cardPack.color?.asColor?.withOpacity(0.1), ), + alignment: Alignment.center, clipBehavior: Clip.antiAlias, - child: _CardsView(_cardsViewController, cardPack), + child: CardsView(_cardsViewController, cardPack), + ), + Container( + height: 1, + color: cardPack.color?.asColor ?? + Colors.grey.withOpacity(0.5), ), SizedBox( height: 10.h, ), Padding( padding: EdgeInsets.symmetric( - vertical: 8.0.h, horizontal: 10.w, ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - _Button('view.png', () async { - _cardsViewController.toggleExpanded(); - }), + StreamBuilder( + stream: _cardsViewController.asStream, + builder: (context, snapshot) { + return AnimatedRotation( + duration: Duration(milliseconds: 300), + turns: (snapshot.data?.expanded ?? false) + ? 0.5 + : 0.0, + child: _Button( + 'down.png', + () async { + if (snapshot.hasData) { + _cardsViewController.setExpanded( + !snapshot.requireData.expanded, + ); + } + }, + ), + ); + }), _Button('shuffle.png', () async { - final index = - Random().nextInt(cardPack.cards.length); + final cards = _cardsViewController.state.packCards; + if (cards.isEmpty) return; + final index = (_cardsViewController.offsetCards + + Random().nextInt( + (cards.length / 2).ceil(), + ) + + 3) % + cards.length; await _cardsViewController.animateTo(index); AutoRouter.of(context).push( PageRouteInfo( CardGamePage.name, args: CardGamePageArgs( - cardPack: cardPack, - startCardId: cardPack.cards[index].id, + cards: cards, + color: cardPack.color?.asColor, + startCardId: cards[index].id, infinite: true, + shuffle: true, // id: cardPack.dto.id.toString(), ), ), ); }), // _Button('chat.png', () {}), - _Button('heart.png', () {}), + StreamBuilder( + stream: _cardsViewController.asStream, + builder: (context, snapshot) { + return _Button( + (snapshot.data?.favorite ?? false) + ? 'heart_fill.png' + : 'heart.png', () { + if (snapshot.hasData) { + _cardsViewController.setFavorite( + !snapshot.requireData.favorite, + ); + } + }); + }), ], ), ), @@ -124,24 +174,28 @@ class _TestsSection extends StatelessWidget { @override Widget build(BuildContext context) { - return FutureBuilder( - future: locator.testManager.loadTests(packId: packId), + return StreamBuilder( + stream: locator.testManager.loadTestsStream(packId: packId), builder: (context, snapshot) { return Column( children: [ if (snapshot.hasData) - ...snapshot.data!.map( - (dto) => _TestButton( - title: dto.name, - subtitle: null, - time: dto.time, - timeSubtitle: dto.timeSubtitle, - onTap: () { - AutoRouter.of(context).push( - PageRouteInfo(TestPage.name, - args: TestPageArgs(testId: dto.id!)), - ); - }, + ...snapshot.data!.mapIndexed( + (int index, TestDto dto) => FadeInWidget( + delay: Duration(milliseconds: index * 200), + animationDuration: const Duration(milliseconds: 200), + child: _TestButton( + title: dto.name, + subtitle: null, + time: dto.time, + timeSubtitle: dto.timeSubtitle, + onTap: () { + AutoRouter.of(context).push( + PageRouteInfo(TestPage.name, + args: TestPageArgs(testId: dto.id!)), + ); + }, + ), ), ), ], @@ -152,28 +206,33 @@ class _TestsSection extends StatelessWidget { } class _AdTile extends StatelessWidget { - const _AdTile(); + const _AdTile({super.key}); @override Widget build(BuildContext context) { final theme = Theme.of(context).textTheme; - return Container( - height: 80, - margin: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0), - decoration: BoxDecoration( - border: Border.all( - color: Colors.white, - width: 4.0, + return LayoutBuilder(builder: (context, constraints) { + final banner = YandexAds.createBanner( + BoxConstraints.tight(Size(constraints.maxWidth - 16, 80 - 8))); + return FadeInWidget( + future: banner.loadAd(), + animationDuration: Duration(milliseconds: 1000), + child: Container( + height: 80, + margin: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0), + decoration: BoxDecoration( + border: Border.all( + color: Colors.white, + width: 4.0, + ), + borderRadius: BorderRadius.circular(12.0), + ), + child: Center( + child: AdWidget(bannerAd: banner), + ), ), - borderRadius: BorderRadius.circular(12.0), - ), - child: Center( - child: LayoutBuilder(builder: (context, constraints) { - return AdWidget( - bannerAd: YandexAds.createBanner(constraints)..loadAd()); - }), - ), - ); + ); + }); } } @@ -181,10 +240,7 @@ class _Button extends StatelessWidget { final String asset; final VoidCallback onTap; - _Button( - this.asset, - this.onTap, - ); + _Button(this.asset, this.onTap, {super.key}); @override Widget build(BuildContext context) { @@ -305,206 +361,3 @@ class _TestButton extends StatelessWidget { ); } } - -class _CardBuilder extends StatelessWidget { - final GameCardDto card; - final CardPackDto cardPack; - final MemoryImage image; - final Function(GameCardDto) onTap; - - _CardBuilder( - this.cardPack, - this.card, - this.image, - this.onTap, - ); - - @override - Widget build(BuildContext context) { - return GestureDetector( - key: ObjectKey(card), - onTap: () => onTap(card), - child: Hero( - tag: card.id, - child: GameCardDecoration( - alpha: 0.0, - child: FrontCard( - card, - image, - alpha: 0, - ), - ), - createRectTween: (a, b) => RectTween(begin: a, end: b), - flightShuttleBuilder: (flightC, a, dir, fromC, toC) => AnimatedBuilder( - animation: a, - builder: (context, child) => GameCardDecoration( - alpha: a.value, - child: FrontCard( - card, - card.memoryImage!, - alpha: a.value, - ), - ), - ), - ), - ); - } -} - -class _CardsView extends StatefulWidget { - final CardPackDto cardPack; - final _CardsViewController controller; - - _CardsView(this.controller, this.cardPack); - - @override - State createState() => _CardsViewState(); -} - -class _CardsViewState extends State<_CardsView> - with SingleTickerProviderStateMixin { - CardPackDto get cardPack => widget.cardPack; - - _CardsViewController get controller => widget.controller; - - late AnimationController _animationController; - - bool get expanded => controller._expanded; - - void _onStateChanged() { - if (expanded) { - _animationController.animateTo(1.0); - } else { - _animationController.animateBack(0.0); - } - // setState(() {}); - } - - @override - void didUpdateWidget(_CardsView oldWidget) { - super.didUpdateWidget(oldWidget); - controller.addListener(_onStateChanged); - } - - @override - void initState() { - super.initState(); - _animationController = AnimationController( - vsync: this, - duration: Duration(milliseconds: 500), - reverseDuration: Duration(milliseconds: 500)); - controller.addListener(_onStateChanged); - } - - @override - void dispose() { - controller.removeListener(_onStateChanged); - _animationController.dispose(); - super.dispose(); - } - - void onCardTap(GameCardDto card) async { - final playing = appRouter.push( - PageRouteInfo( - CardGamePage.name, - args: CardGamePageArgs( - cardPack: cardPack, - startCardId: card.id, - infinite: true, - // id: cardPack.dto.id.toString(), - ), - ), - ); - final id = await playing; - if (id is int) { - final index = cardPack.cards.indexWhere((c) => c.id == id); - if (index >= 0) { - print('jump to $index'); - controller.jumpTo(index); - } - } - } - - @override - Widget build(BuildContext context) { - final cardWidth = MediaQuery.of(context).size.width / 3; - final imageWidth = cardWidth * 0.93; - final packCards = cardPack.cards; - controller.imageWidth = imageWidth; - final animation = _animationController.view; - return AnimatedBuilder( - animation: animation, - builder: (context, child) { - return SingleChildScrollView( - controller: controller._cardsScrollController, - scrollDirection: animation.value > 0 ? Axis.vertical : Axis.horizontal, - physics: animation.value > 0 ? NeverScrollableScrollPhysics() : null, - child: Container( - alignment: Alignment.topCenter, - height: cardWidth * 4 / 3 + - (packCards.length * 4 / 9 * cardWidth - cardWidth * 4 / 3) * - animation.value, - child: Wrap( - children: List.generate(packCards.length, (index) { - final card = packCards[index]; - final memoryImage = card.id.memoryImage; - if (memoryImage == null) return SizedBox(); - return Container( - height: cardWidth * 4 / 3, - width: cardWidth, - padding: EdgeInsets.symmetric( - horizontal: cardWidth - imageWidth - 1, - vertical: cardWidth - imageWidth - 1, - ), - alignment: Alignment.topCenter, - child: Container( - height: imageWidth * 4 / 3, - child: _CardBuilder( - cardPack, card, memoryImage, onCardTap)), - ); - }), - ), - ), - ); - }); - } -} - -class _CardsViewController extends ChangeNotifier { - bool _expanded = false; - final ScrollController _cardsScrollController = - ScrollController(keepScrollOffset: true); - - double imageWidth = 0.0; - - void setExpanded(bool expanded) { - if (_expanded != expanded) { - _expanded = expanded; - notifyListeners(); - } - } - - void toggleExpanded() => setExpanded(!_expanded); - - _CardsViewController(); - - Future animateTo(int index) { - final millis = - ((_cardsScrollController.offset / imageWidth - index).abs() * 30.0) - .ceil(); - return _cardsScrollController.animateTo( - imageWidth * index, - duration: Duration(milliseconds: millis), - curve: Curves.easeInOut, - ); - } - - void jumpTo(int index) { - final millis = - ((_cardsScrollController.offset / imageWidth - index).abs() * 30.0) - .ceil(); - return _cardsScrollController.jumpTo( - imageWidth * index, - ); - } -} diff --git a/lib/widgets/card_pack/buy_pack_page.dart b/lib/widgets/card_pack/buy_pack_page.dart index b2d0664..52cde04 100644 --- a/lib/widgets/card_pack/buy_pack_page.dart +++ b/lib/widgets/card_pack/buy_pack_page.dart @@ -1,15 +1,22 @@ import 'dart:convert'; +import 'dart:developer'; +import 'package:auto_route/auto_route.dart'; import 'package:mnemo_cards/utils/string_helper.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart'; +import '../../di/injector.dart'; +import '../../features/analytics/analytics.dart'; +import '../../features/packs/pack_holder.dart'; +import '../../main.dart'; import '../items/builder.dart'; import '../../di/locator.dart'; import '../../domain/router/app_router.dart'; import '../../theme/themes.dart'; import 'card_pack_header.dart'; +import 'loading_pack.dart'; class BuyPack extends StatelessWidget { final CardPackBuyDto dto; @@ -18,74 +25,135 @@ class BuyPack extends StatelessWidget { @override Widget build(BuildContext context) { + bool loading = false; + Analytics.buyPackOpened(dto); return Container( // color: dto.color?.asColor, - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - CardPackHeader(dto.title, dto.subtitle, dto.color?.asColor), - Expanded( - child: Column( - children: [ - Padding( - padding: - EdgeInsets.symmetric(horizontal: 10.0.w, vertical: 18.h), - child: LayoutBuilder(builder: (context, constraints) { - final cardWidth = constraints.maxWidth / 3; - final imageWidth = cardWidth * 0.9; - return SizedBox( - height: 150.h, - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: dto.cards.length, - itemExtent: cardWidth, - itemBuilder: (BuildContext context, int index) { - final card = dto.cards[index]; - if (card.image != null) { - return Container( - alignment: Alignment.center, - width: imageWidth, - margin: EdgeInsets.symmetric( - horizontal: cardWidth - imageWidth - 1, - vertical: 1, - ), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8.0), - border: Border.all( - color: borderGray, + child: StatefulBuilder(builder: (context, setState) { + if (loading) { + return _Loading(dto); + } + return Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + CardPackHeader(dto.title, dto.subtitle, dto.color?.asColor), + Expanded( + child: Column( + children: [ + Padding( + padding: EdgeInsets.symmetric( + horizontal: 10.0.w, vertical: 18.h), + child: LayoutBuilder(builder: (context, constraints) { + final cardWidth = constraints.maxWidth / 3; + final imageWidth = cardWidth * 0.9; + return SizedBox( + height: 150.h, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: dto.cards.length, + itemExtent: cardWidth, + itemBuilder: (BuildContext context, int index) { + final card = dto.cards[index]; + if (card.image != null) { + return Container( + alignment: Alignment.center, + width: imageWidth, + margin: EdgeInsets.symmetric( + horizontal: cardWidth - imageWidth - 1, + vertical: 1, ), - ), - child: Image.memory(base64Decode(card.image!)), - ); - } - return SizedBox.shrink(); - }, - ), - ); - }), - ), - SizedBox( - height: 32.h, - ), - ...?dto.items?.build(), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8.0), + border: Border.all( + color: borderGray, + ), + ), + child: Image.memory(base64Decode(card.image!)), + ); + } + return SizedBox.shrink(); + }, + ), + ); + }), + ), + SizedBox( + height: 32.h, + ), + ...?dto.items?.build(), + ], + ), + ), + _BuyButton(dto, (v) { + setState(() { + loading = v; + }); + }), + SizedBox( + height: 16.h, + ), + ], + ); + }), + ); + } +} + +class _Loading extends StatelessWidget { + final CardPackBuyDto dto; + + _Loading(this.dto); + + @override + Widget build(BuildContext context) { + final memoryImage = locator.imagesHolder.get('pack_${dto.id}'); + return Column( + children: [ + CardPackHeader(dto.title, dto.subtitle, dto.color?.asColor), + Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (memoryImage != null) + Padding( + padding: const EdgeInsets.all(8.0), + child: Image( + image: memoryImage, + width: 100, + ), + ), + Text('Загрузка...'), ], ), ), - _BuyButton(dto), - SizedBox( - height: 16.h, - ), - ], - ), + ), + ], ); } } class _BuyButton extends StatelessWidget { final CardPackBuyDto dto; + final Function(bool) callback; - _BuyButton(this.dto); + _BuyButton(this.dto, this.callback); + + Future _updatePack() async { + try { + callback(true); + await locator.packUpdater.updatePackAndSaveImages(dto.id); + await locator.packCacheManager.loadPackFromCache(dto.id); + } catch (e, s) { + log( + 'Updating from buy pack', + error: e, + stackTrace: s, + ); + appRouter.maybePop(); + } + } @override Widget build(BuildContext context) { @@ -101,7 +169,11 @@ class _BuyButton extends StatelessWidget { InkWell( onTap: () async { if (locator.userManager.hasUser) { - final result = locator.purchaseService.buyPack(dto); + Analytics.buyPackPressed(dto, PaymentSystem.google); + final result = await locator.purchaseService.buyPack(dto); + if (result) { + _updatePack(); + } } else { AppRouter.openAuth(); } @@ -145,11 +217,15 @@ class _BuyButton extends StatelessWidget { TextButton( style: ButtonStyle( overlayColor: - MaterialStateColor.resolveWith((states) => Colors.white), + WidgetStateColor.resolveWith((states) => Colors.white), ), onPressed: () async { if (locator.userManager.hasUser) { - await locator.purchaseService.buyPackWithYooMoney(dto, context); + Analytics.buyPackPressed(dto, PaymentSystem.yookassa); + if (await locator.purchaseService + .buyPackWithYooMoney(dto, context)) { + _updatePack(); + } } else { AppRouter.openAuth(); } diff --git a/lib/widgets/card_pack/card_pack_page.dart b/lib/widgets/card_pack/card_pack_page.dart index d4429f6..9d10178 100644 --- a/lib/widgets/card_pack/card_pack_page.dart +++ b/lib/widgets/card_pack/card_pack_page.dart @@ -1,37 +1,22 @@ import 'dart:convert'; -import 'dart:math'; -import 'dart:typed_data'; import 'package:auto_route/annotations.dart'; 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/domain/router/app_router.dart'; -import 'package:mnemo_cards/domain/router/app_router.gr.dart'; -import 'package:mnemo_cards/features/packs/images_holder.dart'; -import 'package:mnemo_cards/features/yandex_ads/yandex_ads.dart'; -import 'package:mnemo_cards/utils/iterable_helper.dart'; -import 'package:mnemo_cards/utils/string_helper.dart'; -import 'package:mnemo_cards/widgets/game_card_widget.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart'; -import 'package:yandex_mobileads/mobile_ads.dart'; -import '../../main.dart'; -import '../items/builder.dart'; +import '../../features/analytics/analytics.dart'; import '../../di/locator.dart'; -import '../../theme/themes.dart'; -import '../header.dart'; -import '../mnemo_text.dart'; import 'available_pack.dart'; import 'buy_pack_page.dart'; import 'card_pack_header.dart'; +import 'loading_pack.dart'; @RoutePage() class CardPackPage extends StatefulWidget { - final String cardPackId; + final CardPackPreviewDto previewDto; - const CardPackPage(this.cardPackId, {super.key}); + const CardPackPage(this.previewDto, {super.key}); @override State createState() => _CardPackPage(); @@ -43,14 +28,27 @@ class _CardPackPage extends State { return Scaffold( body: SafeArea( child: StreamBuilder( - stream: locator.packManager.getCardPackStream(widget.cardPackId), + stream: locator.packManager.getCardPackStream( + widget.previewDto.id, + widget.previewDto.isAvailable, + ), initialData: null, builder: (context, snapshot) { - if (!snapshot.hasData) return _Loading(); + if (!snapshot.hasData) return SizedBox.shrink(); + if (snapshot.requireData.responseType == + GetCardPackResponseType.loading) + return Loading(widget.previewDto); final response = snapshot.requireData; if (response is CardPackBuyDto) return BuyPack(response); - else if (response is CardPackDto) return AvailablePack(response); + else if (response is CardPackDto) { + return AvailablePack(response); + } + Analytics.error( + data: {'response': jsonEncode(snapshot.data?.toJson() ?? '{}')}, + message: 'cant load pack ${widget.previewDto.id}', + ); + return Column( children: [ CardPackHeader.fromDto(null), @@ -71,19 +69,3 @@ class _CardPackPage extends State { super.initState(); } } - -class _Loading extends StatelessWidget { - @override - Widget build(BuildContext context) { - return Column( - children: [ - CardPackHeader.fromDto(null), - Expanded( - child: Center( - child: Text('Loading...'), - ), - ), - ], - ); - } -} diff --git a/lib/widgets/card_pack/cards_view.dart b/lib/widgets/card_pack/cards_view.dart new file mode 100644 index 0000000..3357ed9 --- /dev/null +++ b/lib/widgets/card_pack/cards_view.dart @@ -0,0 +1,263 @@ +import 'dart:async'; + +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:mnemo_cards/features/packs/images_holder.dart'; +import 'package:mnemo_cards_common/mnemo_cards_common.dart'; +import 'package:collection/collection.dart'; +import 'package:rxdart/rxdart.dart'; + +import '../../di/locator.dart'; +import '../../utils/string_helper.dart'; +import '../../domain/router/app_router.gr.dart'; +import '../../main.dart'; +import '../game_card_widget.dart'; +import 'cards_view_controller.dart'; + +class CardsView extends StatefulWidget { + final CardPackDto cardPack; + final CardsViewController controller; + + CardsView(this.controller, this.cardPack); + + @override + State createState() => _CardsViewState(); +} + +class _CardsViewState extends State + with SingleTickerProviderStateMixin { + CardPackDto get cardPack => widget.cardPack; + + CardsViewController get controller => widget.controller; + var _subscription = CompositeSubscription(); + + late AnimationController _animationController; + + bool expanded = false; + bool favorite = false; + + final wrapKey = GlobalKey(); + + void _onStateChanged(CardsViewState state) async { + favorite = state.favorite; + if (state.expanded != expanded) { + expanded = state.expanded; + if (expanded) { + await controller.animateTo(0); + _animationController.animateTo( + 1.0, + duration: + Duration(milliseconds: (100 * state.packCards.length / 3).ceil()), + ); + } else { + _animationController.animateBack( + 0.0, + duration: + Duration(milliseconds: (100 * state.packCards.length / 3).ceil()), + ); + } + } + } + + @override + void didUpdateWidget(CardsView oldWidget) { + super.didUpdateWidget(oldWidget); + _subscription.cancel(); + _subscription = CompositeSubscription(); + controller.asStream.listen(_onStateChanged).addTo(_subscription); + } + + @override + void initState() { + super.initState(); + expanded = controller.state.expanded; + favorite = controller.state.favorite; + _animationController = AnimationController(vsync: this); + _subscription.cancel(); + _subscription = CompositeSubscription(); + controller.asStream.listen(_onStateChanged).addTo(_subscription); + } + + @override + void dispose() { + _subscription.cancel(); + _animationController.dispose(); + super.dispose(); + } + + void onCardTap(List cards, GameCardDto card) async { + final playing = appRouter.push( + PageRouteInfo( + CardGamePage.name, + args: CardGamePageArgs( + cards: cards, + color: cardPack.color?.asColor, + startCardId: card.id, + infinite: true, + shuffle: false, + // id: cardPack.dto.id.toString(), + ), + ), + ); + final id = await playing; + // if (id is int) { + // final index = cardPack.cards.indexWhere((c) => c.id == id); + // if (index >= 0) { + // print('jump to $index'); + // controller.jumpTo(index); + // } + // } + } + + @override + Widget build(BuildContext context) { + final cardWidth = MediaQuery.of(context).size.width / 3; + final imageWidth = cardWidth * 0.93; + + return StreamBuilder( + stream: controller.cardsStream, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return SizedBox(); + } + final packCards = snapshot.requireData; + + if (packCards.isEmpty) { + return _NoCards( + favorite: favorite, + height: cardWidth * 4 / 3, + ); + } + + controller.imageWidth = imageWidth; + final animation = _animationController.view; + return AnimatedBuilder( + animation: animation, + builder: (context, child) { + return Column( + children: [ + SingleChildScrollView( + controller: controller.cardsScrollController, + scrollDirection: + animation.value > 0 ? Axis.vertical : Axis.horizontal, + physics: animation.value > 0 + ? NeverScrollableScrollPhysics() + : null, + child: Container( + alignment: Alignment.topCenter, + height: cardWidth * 4 / 3 + + ((packCards.length / 3).ceil() * 4 / 3 * cardWidth - + cardWidth * 4 / 3) * + animation.value, + child: Wrap( + key: wrapKey, + children: List.generate(packCards.length, (index) { + final card = packCards[index]; + final memoryImage = card.memoryImage; + if (memoryImage == null) { + return SizedBox(); + } + return Container( + height: cardWidth * 4 / 3, + width: cardWidth, + padding: EdgeInsets.symmetric( + horizontal: cardWidth - imageWidth - 1, + vertical: cardWidth - imageWidth - 1, + ), + alignment: Alignment.topCenter, + child: Container( + height: imageWidth * 4 / 3, + child: _CardBuilder( + cardPack, + card, + memoryImage, + (card) => onCardTap(packCards, card), + ), + ), + ); + }), + ), + ), + ), + ], + ); + }); + }); + } +} + +class _CardBuilder extends StatelessWidget { + final GameCardDto card; + final CardPackDto cardPack; + final MemoryImage image; + final Function(GameCardDto) onTap; + + _CardBuilder( + this.cardPack, + this.card, + this.image, + this.onTap, + ); + + @override + Widget build(BuildContext context) { + return GestureDetector( + key: ObjectKey(card), + onTap: () => onTap(card), + child: Hero( + key: ValueKey('${card.id}'), + tag: card.id, + child: GameCardDecoration( + alpha: 0.0, + borderColor: cardPack.color?.asColor, + child: FrontCard( + card, + image, + alpha: 0, + ), + ), + createRectTween: (a, b) => RectTween(begin: a, end: b), + flightShuttleBuilder: (flightC, a, dir, fromC, toC) { + return AnimatedBuilder( + animation: a, + builder: (context, child) => GameCardDecoration( + alpha: a.value, + child: FrontCard( + card, + card.memoryImage!, + alpha: a.value, + ), + ), + ); + }, + ), + ); + } +} + +class _NoCards extends StatelessWidget { + final bool favorite; + final double height; + + _NoCards({ + required this.favorite, + required this.height, + }); + + @override + Widget build(BuildContext context) { + return Container( + height: height, + alignment: Alignment.center, + child: Text( + favorite ? 'Нет любимых карточек' : 'Пока нет карточек', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ); + } +} diff --git a/lib/widgets/card_pack/cards_view_controller.dart b/lib/widgets/card_pack/cards_view_controller.dart new file mode 100644 index 0000000..8493216 --- /dev/null +++ b/lib/widgets/card_pack/cards_view_controller.dart @@ -0,0 +1,126 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/widgets.dart'; +import 'package:mnemo_cards/features/packs/images_holder.dart'; +import 'package:mnemo_cards_common/mnemo_cards_common.dart'; +import 'package:rxdart/rxdart.dart'; + +import '../../di/locator.dart'; + +class CardsViewController extends ChangeNotifier { + bool _expanded = false; + bool _favorite = false; + double imageWidth = 0.0; + List cards = []; + + final CardPackDto cardPack; + + final ScrollController _cardsScrollController = + ScrollController(keepScrollOffset: true); + + ScrollController get cardsScrollController => _cardsScrollController; + + StreamController _streamController = + StreamController.broadcast(); + + Stream get asStream => + _streamController.stream.startWith(state).distinct(); + + Stream get expandedStream => asStream.map((s) => s.expanded).distinct(); + + Stream get favoriteStream => asStream.map((s) => s.favorite).distinct(); + + Stream> get cardsStream => + asStream.map((s) => s.packCards).distinct((p, n) => + (p.length == n.length) && + p.indexed.every((p) => n[p.$1].id == p.$2.id)); + + CardsViewState get state => CardsViewState( + expanded: _expanded, + favorite: _favorite, + packCards: cards, + ); + + List _filterPackCards() => cardPack.cards + .where( + (card) => + !_favorite || + locator.favoriteCardsController.isFavoriteCard(card.id), + ) + .toList(); + + void setExpanded(bool expanded) { + if (cards.isNotEmpty) { + _expanded = expanded; + notifyListeners(); + } + } + + void setFavorite(bool favorite) { + _favorite = favorite; + _updateCards(); + notifyListeners(); + } + + void _addToStream() { + _streamController.add(state); + } + + double get offset => _cardsScrollController.offset; + + int get offsetCards => (_cardsScrollController.offset / imageWidth).floor(); + + void toggleExpanded() => setExpanded(!_expanded); + + CardsViewController(this.cardPack) { + addListener(_addToStream); + _updateCards(); + } + + void _updateCards() { + cards = _filterPackCards(); + notifyListeners(); + } + + Future animateTo(int index) { + final millis = + ((_cardsScrollController.offset / imageWidth - index).abs() * 30.0) + .ceil(); + return _cardsScrollController.animateTo( + imageWidth * index, + duration: Duration(milliseconds: max(millis, 100)), + curve: Curves.easeInOut, + ); + } + + void jumpTo(int index) { + return _cardsScrollController.jumpTo( + imageWidth * index, + ); + } +} + +class CardsViewState { + final bool expanded; + final bool favorite; + final List packCards; + + CardsViewState({ + required this.expanded, + required this.favorite, + required this.packCards, + }); + + @override + int get hashCode => expanded.hashCode ^ expanded.hashCode ^ packCards.length; + + @override + bool operator ==(Object other) { + return (other is CardsViewState) && + expanded == other.expanded && + favorite == other.favorite && + (packCards.length == other.packCards.length) && + packCards.indexed.every((p) => other.packCards[p.$1].id == p.$2.id); + } +} diff --git a/lib/widgets/card_pack/heart_button.dart b/lib/widgets/card_pack/heart_button.dart new file mode 100644 index 0000000..c9c1974 --- /dev/null +++ b/lib/widgets/card_pack/heart_button.dart @@ -0,0 +1,10 @@ +import 'package:flutter/cupertino.dart'; + +class HeartButton extends StatelessWidget { + @override + Widget build(BuildContext context) { + // TODO: implement build + throw UnimplementedError(); + } + +} \ No newline at end of file diff --git a/lib/widgets/card_pack/loading_pack.dart b/lib/widgets/card_pack/loading_pack.dart new file mode 100644 index 0000000..ef05e23 --- /dev/null +++ b/lib/widgets/card_pack/loading_pack.dart @@ -0,0 +1,44 @@ +import 'package:flutter/widgets.dart'; +import 'package:mnemo_cards_common/mnemo_cards_common.dart'; + +import '../../di/locator.dart'; +import '../../utils/string_helper.dart'; +import 'card_pack_header.dart'; + +class Loading extends StatelessWidget { + final CardPackPreviewDto previewDto; + + Loading(this.previewDto); + + @override + Widget build(BuildContext context) { + final memoryImage = locator.imagesHolder.get('pack_${previewDto.id}'); + return Column( + children: [ + CardPackHeader( + previewDto.title, + previewDto.subtitle, + previewDto.color?.asColor, + ), + Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (memoryImage != null) + Padding( + padding: const EdgeInsets.all(8.0), + child: Image( + image: memoryImage, + width: 100, + ), + ), + Text('Загрузка...'), + ], + ), + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/widgets/fade_in_widget.dart b/lib/widgets/fade_in_widget.dart new file mode 100644 index 0000000..22a2fc7 --- /dev/null +++ b/lib/widgets/fade_in_widget.dart @@ -0,0 +1,29 @@ +import 'package:flutter/cupertino.dart'; + +class FadeInWidget extends StatelessWidget { + final Widget child; + final Duration? delay; + final Future? future; + final Duration animationDuration; + + FadeInWidget({ + required this.child, + this.delay = const Duration(milliseconds: 200), + this.future, + this.animationDuration = const Duration(milliseconds: 200), + }) : assert(future != null || delay != null); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: future ?? Future.delayed(delay!), + builder: (context, s) { + return AnimatedOpacity( + duration: animationDuration, + opacity: s.connectionState == ConnectionState.done ? 1.0 : 0.0, + child: child, + ); + }, + ); + } +} diff --git a/lib/widgets/game_card_widget.dart b/lib/widgets/game_card_widget.dart index 9dc91d1..3121e81 100644 --- a/lib/widgets/game_card_widget.dart +++ b/lib/widgets/game_card_widget.dart @@ -47,13 +47,23 @@ class GameCardWidget extends StatefulWidget { class GameCardDecoration extends StatelessWidget { final Widget child; final double alpha; + final Color? borderColor; - GameCardDecoration({required this.child, this.alpha = 1.0}); + GameCardDecoration({ + required this.child, + this.alpha = 1.0, + this.borderColor, + }); @override Widget build(BuildContext context) => Container( padding: EdgeInsets.all(8.0 * alpha), - margin: EdgeInsets.all(4.0 * alpha), + margin: EdgeInsets.only( + left: 10.0 * alpha, + right: 10.0 * alpha, + bottom: 10.0 * alpha, + top: 0.0 * alpha, + ), decoration: BoxDecoration( color: Colors.white, boxShadow: [ @@ -66,7 +76,7 @@ class GameCardDecoration extends StatelessWidget { borderRadius: BorderRadius.circular( 8 + (GameCardWidget.borderRadius - 8) * alpha), border: Border.all( - color: borderGray.withOpacity(1.0 - alpha * alpha), + color: (borderColor ?? borderGray).withOpacity(1.0 - alpha * alpha), ), ), child: ClipRRect( @@ -160,7 +170,7 @@ class _BackWidget extends StatelessWidget { ), child: Center( child: MnemoText( - card.back ?? card.translation ?? '', + card.back?.isNotEmpty == true ? card.back : card.translation ?? '', textStyle: theme.displayMedium!.copyWith(color: Colors.white), maxLines: 2, softWrap: true, @@ -199,8 +209,8 @@ class FrontCard extends StatelessWidget { alignment: Alignment.center, children: [ Padding( - padding: - EdgeInsets.all(8.0 * imageScale * imageScale * alpha), + padding: EdgeInsets.all( + 8.0 * imageScale * (alpha / 2 + 0.5)), child: Column( mainAxisSize: MainAxisSize.max, mainAxisAlignment: ar > 1 @@ -213,7 +223,7 @@ class FrontCard extends StatelessWidget { child: MnemoText( card.original ?? '', textStyle: TextStyle( - fontSize: 27 * textScale, + fontSize: 15 * textScale + 12, fontWeight: FontWeight.w600, ), maxLines: 1, @@ -229,33 +239,30 @@ class FrontCard extends StatelessWidget { } }, ), - if (alpha > 0) - GestureDetector( - onTap: () async { - final sp = - await SharedPreferences.getInstance(); - if (sp.getBool('sound_on') != false && - card.translation != null) { - FlutterTts flutterTts = FlutterTts(); - await flutterTts.setLanguage('ru'); - await flutterTts.setSpeechRate(0.5); - flutterTts.speak( - card.translation!, - ); - } - }, - child: MnemoText( - card.translation?.toLowerCase(), - textStyle: TextStyle( - fontSize: 20 * textScale * alpha, - fontWeight: FontWeight.w400, - color: theme.headlineSmall!.color! - .withOpacity(0.5 * alpha), - ), - maxLines: 1, - // group: smallSize, + GestureDetector( + onTap: () async { + final sp = await SharedPreferences.getInstance(); + if (sp.getBool('sound_on') != false && + card.translation != null) { + FlutterTts flutterTts = FlutterTts(); + await flutterTts.setLanguage('ru'); + await flutterTts.setSpeechRate(0.5); + flutterTts.speak( + card.translation!, + ); + } + }, + child: MnemoText( + card.translation?.toLowerCase(), + textStyle: TextStyle( + fontSize: 12 * textScale * alpha + 8, + fontWeight: FontWeight.w400, + color: theme.headlineSmall!.color! + .withOpacity(0.5), ), + maxLines: 1, ), + ), if (alpha > 0 && ar > 1 && card.transcription != null && @@ -289,12 +296,17 @@ class FrontCard extends StatelessWidget { ), ], ), - SizedBox( + Container( width: constrains.maxHeight / ar * imageScale, height: constrains.maxHeight / ar * imageScale, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + GameCardWidget.borderRadius)), child: ImageFade( key: ValueKey(card.id), image: image, + // fix for elapsedInSeconds < 0 in debug mode syncDuration: Duration.zero, ), ), diff --git a/lib/widgets/image_fade.dart b/lib/widgets/image_fade.dart index 255670e..fe21d68 100644 --- a/lib/widgets/image_fade.dart +++ b/lib/widgets/image_fade.dart @@ -34,7 +34,8 @@ typedef ImageFadeLoadingBuilder = Widget Function( class ImageFade extends StatefulWidget { /// Creates a widget that displays a [placeholder] widget while a specified [image] loads, /// then cross-fades to the loaded image. - const ImageFade({ + const + ImageFade({ Key? key, this.placeholder, this.image, diff --git a/lib/widgets/items/builder.dart b/lib/widgets/items/builder.dart index 8c5d161..30d1966 100644 --- a/lib/widgets/items/builder.dart +++ b/lib/widgets/items/builder.dart @@ -33,6 +33,7 @@ class TextItemBuilder extends StatelessWidget { @override Widget build(BuildContext context) { return Container( + padding: EdgeInsets.all(8.0), decoration: BoxDecoration( border: item.hasBorder ? Border.all( @@ -50,6 +51,7 @@ class TextItemBuilder extends StatelessWidget { fontSize: 24, color: Colors.black, ), + textAlign: TextAlign.center, ), if (item.subtitle != null) Text( diff --git a/lib/widgets/packs_menu_widget.dart b/lib/widgets/packs_menu_widget.dart index c431034..b8f993f 100644 --- a/lib/widgets/packs_menu_widget.dart +++ b/lib/widgets/packs_menu_widget.dart @@ -97,7 +97,13 @@ class _PacksMenuWidgetState extends State { padding: const EdgeInsets.all(8.0), child: RefreshIndicator( onRefresh: locator.previewPackPoller.poll, + backgroundColor: Colors.white, + color: snapshot.data?.firstOrNull?.color?.asColor ?? + Colors.black, child: ListView( + physics: BouncingScrollPhysics( + decelerationRate: ScrollDecelerationRate.fast, + ), scrollDirection: Axis.vertical, children: [ if (snapshot.data == null) @@ -127,7 +133,9 @@ class _PacksMenuWidgetState extends State { if (pack.imageBase64 != null) { try { final bytes = base64Decode(pack.imageBase64!); - await precacheImage(MemoryImage(bytes), context); + final image = MemoryImage(bytes); + locator.imagesHolder.setImage('pack_${pack.id}', image); + await precacheImage(image, context); } catch (e) { log('${pack.id} cover not cached'); } @@ -154,7 +162,9 @@ class _PackButton4 extends StatelessWidget { AutoRouter.of(context).push( PageRouteInfo( CardPackPage.name, - args: CardPackPageArgs(cardPackId: pack.id), + args: CardPackPageArgs( + previewDto: pack, + ), ), ); }, @@ -183,9 +193,10 @@ class _PackButton4 extends StatelessWidget { image: pack.imageBase64 == null ? null : DecorationImage( - image: MemoryImage( - base64Decode(pack.imageBase64!), - ), + image: locator.imagesHolder.get('pack_${pack.id}') ?? + MemoryImage( + base64Decode(pack.imageBase64!), + ), ), ), margin: EdgeInsets.all(2.0.w), @@ -276,7 +287,7 @@ class _PackButton4Shimmer extends StatelessWidget { Widget build(BuildContext context) { return Shimmer.fromColors( baseColor: Colors.white, - highlightColor: Colors.white, + highlightColor: borderGray, child: Container( alignment: Alignment.center, height: cardHeight, diff --git a/pubspec.lock b/pubspec.lock index cb94631..92ce8a6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -13,10 +13,10 @@ packages: dependency: transitive description: name: _flutterfire_internals - sha256: "2350805d7afefb0efe7acd325cb19d3ae8ba4039b906eade3807ffb69938a01f" + sha256: "37a42d06068e2fe3deddb2da079a8c4d105f241225ba27b7122b37e9865fd8f7" url: "https://pub.dev" source: hosted - version: "1.3.33" + version: "1.3.35" analyzer: dependency: transitive description: @@ -353,14 +353,38 @@ packages: url: "https://pub.dev" source: hosted version: "8.0.3" + firebase_analytics: + dependency: "direct main" + description: + name: firebase_analytics + sha256: dbf1e7ab22cfb1f4a4adb103b46a26276b4edc593d4a78ef6fb942bafc92e035 + url: "https://pub.dev" + source: hosted + version: "10.10.7" + firebase_analytics_platform_interface: + dependency: transitive + description: + name: firebase_analytics_platform_interface + sha256: "3729b74f8cf1d974a27ba70332ecb55ff5ff560edc8164a6469f4a055b429c37" + url: "https://pub.dev" + source: hosted + version: "3.10.8" + firebase_analytics_web: + dependency: transitive + description: + name: firebase_analytics_web + sha256: "019cd7eee74254d33fbd2e29229367ce33063516bf6b3258a341d89e3b0f1655" + url: "https://pub.dev" + source: hosted + version: "0.5.7+7" firebase_core: dependency: "direct main" description: name: firebase_core - sha256: "372d94ced114b9c40cb85e18c50ac94a7e998c8eec630c50d7aec047847d27bf" + sha256: "26de145bb9688a90962faec6f838247377b0b0d32cc0abecd9a4e43525fc856c" url: "https://pub.dev" source: hosted - version: "2.31.0" + version: "2.32.0" firebase_core_platform_interface: dependency: transitive description: @@ -377,6 +401,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.17.0" + firebase_crashlytics: + dependency: "direct main" + description: + name: firebase_crashlytics + sha256: "9897c01efaa950d2f6da8317d12452749a74dc45f33b46390a14cfe28067f271" + url: "https://pub.dev" + source: hosted + version: "3.5.7" + firebase_crashlytics_platform_interface: + dependency: transitive + description: + name: firebase_crashlytics_platform_interface + sha256: "16a71e08fbf6e00382816e1b13397898c29a54fa0ad969c2c2a3b82a704877f0" + url: "https://pub.dev" + source: hosted + version: "3.6.35" fixnum: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 40aaf1d..9061864 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -57,6 +57,8 @@ dependencies: device_info_plus: ^9.1.2 package_info_plus: ^8.0.0 yandex_mobileads: ^6.1.0 + firebase_crashlytics: ^3.5.7 + firebase_analytics: ^10.10.7 in_app_purchase: ^3.2.0 dot_navigation_bar: ^1.0.2 story: ^1.1.0