This commit is contained in:
Dmitry 2024-05-29 00:25:51 +03:00
parent 7c055f64f3
commit 7aecd2dd09
44 changed files with 1578 additions and 680 deletions

BIN
icons/down.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 976 B

BIN
icons/heart_fill.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

View file

@ -274,7 +274,9 @@ class EditCard {
TextField( TextField(
controller: backController, controller: backController,
onChanged: (v) { onChanged: (v) {
gameCardDto = gameCardDto.copyWith(back: v); gameCardDto = gameCardDto.copyWith(
back: v,
);
}, },
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Задняя сторона', hintText: 'Задняя сторона',

View file

@ -14,8 +14,10 @@ class AdminApi with Api {
AdminApi(this._dio); AdminApi(this._dio);
Future<bool> addCard(GameCardDto dto) async { Future<bool> addCard(GameCardDto dto) async {
final r = await _dio.post<String>('$path/cards/add', final r = await _dio.post<String>(
data: jsonEncode(dto.toJson())); '$path/cards/add',
data: jsonEncode(dto.toJson()),
);
return r.statusCode == 200; return r.statusCode == 200;
} }

View file

@ -24,7 +24,9 @@ import '../managers/user_manager.dart';
GetIt getIt = GetIt.I; GetIt getIt = GetIt.I;
Future<void> setInjections() async { Future<void> setInjections() async {
getIt.registerSingletonAsync<DioProvider>(() => DioProvider().init()); getIt.registerSingletonAsync<DioProvider>(
() => DioProvider().init(),
);
getIt.registerLazySingleton<HttpRepository>( getIt.registerLazySingleton<HttpRepository>(
() => HttpRepository(getIt.get<DioProvider>().dio), () => HttpRepository(getIt.get<DioProvider>().dio),
); );
@ -96,4 +98,5 @@ Future<void> setInjections() async {
getIt.registerLazySingleton( getIt.registerLazySingleton(
() => PurchaseDetailsStreamSubscription(), () => PurchaseDetailsStreamSubscription(),
); );
await getIt.allReady();
} }

View file

@ -1,4 +1,5 @@
import 'package:get_it/get_it.dart'; 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/features/packs/preview_packs_poller.dart';
import 'package:mnemo_cards/managers/favorite_cards.dart'; import 'package:mnemo_cards/managers/favorite_cards.dart';
import 'package:mnemo_cards/managers/repository/http_repository.dart'; import 'package:mnemo_cards/managers/repository/http_repository.dart';
@ -32,6 +33,8 @@ class Locator {
PackUpdater get packUpdater => _getIt.get<PackUpdater>(); PackUpdater get packUpdater => _getIt.get<PackUpdater>();
PreviewPackHolder get previewPackHolder => _getIt.get<PreviewPackHolder>();
PreviewPacksPoller get previewPackPoller => _getIt.get<PreviewPacksPoller>(); PreviewPacksPoller get previewPackPoller => _getIt.get<PreviewPacksPoller>();
TestManager get testManager => _getIt.get<TestManager>(); TestManager get testManager => _getIt.get<TestManager>();

View file

View file

@ -8,8 +8,11 @@
// coverage:ignore-file // coverage:ignore-file
// ignore_for_file: no_leading_underscores_for_library_prefixes // 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: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/features/tests/test_page.dart' as _i8;
import 'package:mnemo_cards/main.dart' as _i6; import 'package:mnemo_cards/main.dart' as _i6;
import 'package:mnemo_cards/pages/auth_page.dart' as _i1; import 'package:mnemo_cards/pages/auth_page.dart' as _i1;
@ -36,9 +39,11 @@ abstract class $AppRouter extends _i9.RootStackRouter {
return _i9.AutoRoutePage<dynamic>( return _i9.AutoRoutePage<dynamic>(
routeData: routeData, routeData: routeData,
child: _i2.CardGamePage( child: _i2.CardGamePage(
cardPack: args.cardPack, cards: args.cards,
color: args.color,
startCardId: args.startCardId, startCardId: args.startCardId,
infinite: args.infinite, infinite: args.infinite,
shuffle: args.shuffle,
key: args.key, key: args.key,
), ),
); );
@ -48,7 +53,7 @@ abstract class $AppRouter extends _i9.RootStackRouter {
return _i9.AutoRoutePage<dynamic>( return _i9.AutoRoutePage<dynamic>(
routeData: routeData, routeData: routeData,
child: _i3.CardPackPage( child: _i3.CardPackPage(
args.cardPackId, args.previewDto,
key: args.key, key: args.key,
), ),
); );
@ -105,17 +110,21 @@ class AuthPage extends _i9.PageRouteInfo<void> {
/// [_i2.CardGamePage] /// [_i2.CardGamePage]
class CardGamePage extends _i9.PageRouteInfo<CardGamePageArgs> { class CardGamePage extends _i9.PageRouteInfo<CardGamePageArgs> {
CardGamePage({ CardGamePage({
required _i10.CardPackDto cardPack, required List<_i10.GameCardDto> cards,
required _i11.Color? color,
int? startCardId, int? startCardId,
bool infinite = false, bool infinite = false,
_i11.Key? key, bool shuffle = true,
_i12.Key? key,
List<_i9.PageRouteInfo>? children, List<_i9.PageRouteInfo>? children,
}) : super( }) : super(
CardGamePage.name, CardGamePage.name,
args: CardGamePageArgs( args: CardGamePageArgs(
cardPack: cardPack, cards: cards,
color: color,
startCardId: startCardId, startCardId: startCardId,
infinite: infinite, infinite: infinite,
shuffle: shuffle,
key: key, key: key,
), ),
initialChildren: children, initialChildren: children,
@ -129,23 +138,29 @@ class CardGamePage extends _i9.PageRouteInfo<CardGamePageArgs> {
class CardGamePageArgs { class CardGamePageArgs {
const CardGamePageArgs({ const CardGamePageArgs({
required this.cardPack, required this.cards,
required this.color,
this.startCardId, this.startCardId,
this.infinite = false, this.infinite = false,
this.shuffle = true,
this.key, this.key,
}); });
final _i10.CardPackDto cardPack; final List<_i10.GameCardDto> cards;
final _i11.Color? color;
final int? startCardId; final int? startCardId;
final bool infinite; final bool infinite;
final _i11.Key? key; final bool shuffle;
final _i12.Key? key;
@override @override
String toString() { 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] /// [_i3.CardPackPage]
class CardPackPage extends _i9.PageRouteInfo<CardPackPageArgs> { class CardPackPage extends _i9.PageRouteInfo<CardPackPageArgs> {
CardPackPage({ CardPackPage({
required String cardPackId, required _i10.CardPackPreviewDto previewDto,
_i11.Key? key, _i13.Key? key,
List<_i9.PageRouteInfo>? children, List<_i9.PageRouteInfo>? children,
}) : super( }) : super(
CardPackPage.name, CardPackPage.name,
args: CardPackPageArgs( args: CardPackPageArgs(
cardPackId: cardPackId, previewDto: previewDto,
key: key, key: key,
), ),
initialChildren: children, initialChildren: children,
@ -173,17 +188,17 @@ class CardPackPage extends _i9.PageRouteInfo<CardPackPageArgs> {
class CardPackPageArgs { class CardPackPageArgs {
const CardPackPageArgs({ const CardPackPageArgs({
required this.cardPackId, required this.previewDto,
this.key, this.key,
}); });
final String cardPackId; final _i10.CardPackPreviewDto previewDto;
final _i11.Key? key; final _i13.Key? key;
@override @override
String toString() { String toString() {
return 'CardPackPageArgs{cardPackId: $cardPackId, key: $key}'; return 'CardPackPageArgs{previewDto: $previewDto, key: $key}';
} }
} }

View file

@ -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<void> setUser(UserDto user) {
return _analytics.setUserId(id: user.id?.toString());
}
static Future<void> login(ExternalIdType idType) {
return _analytics.logLogin(loginMethod: idType.name);
}
static Future<void> logout(UserDto? dto) async {
await _analytics.logEvent(
name: 'logout',
parameters: {
'id': dto?.id,
}.asFirebaseMap,
);
return _analytics.setUserId(id: dto?.id?.toString());
}
static Future<void> profilePageOpened(UserDto? dto) {
return _analytics.logScreenView(
screenClass: 'profile',
screenName: 'profile',
parameters: {
'id': dto?.id,
}.asFirebaseMap);
}
static Future<void> availablePackOpened(CardPackDto dto) {
return _analytics.logScreenView(
screenClass: 'pack',
screenName: 'available',
parameters: {
'id': dto.id,
'title': dto.title,
'version': dto.version,
}.asFirebaseMap);
}
static Future<void> 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<void> 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<void> buyError(
CardPackBuyDto? dto,
PaymentSystem system, {
Map<String, String?>? 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<void> buyEvent(
CardPackBuyDto? dto,
PaymentSystem system, {
Map<String, String?>? 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<void> buyComplete(
CardPackBuyDto? dto,
PaymentSystem system, {
Map<String, String?>? 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<void> 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<void> 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<String, String?>? 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<String, Object?> get asFirebaseMap => (Map.from(this)
..removeWhere((k, v) => v == null))
.map((k, v) => MapEntry(k, (v is String || v is num) ? v : v.toString()));
}

View file

@ -6,21 +6,34 @@ import 'package:mnemo_cards_common/mnemo_cards_common.dart';
class ImagesHolder { class ImagesHolder {
Map<String, MemoryImage> _images = {}; Map<String, MemoryImage> _images = {};
Map<String, MemoryImage> _packImages = {};
DateTime? unlockTime; DateTime? unlockTime;
ImagesHolder(); ImagesHolder();
void setPackImages(Map<String, MemoryImage> images) { void setPackImages(Map<String, MemoryImage> 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) { MemoryImage? get(String id) {
final memoryImage = _images[id]; final memoryImage = getPackImage(id) ?? _images[id];
return memoryImage; return memoryImage;
} }
MemoryImage? getPackImage(String id) {
final memoryImage = _packImages[id];
return memoryImage;
}
} }
extension ImagesHolderStringExt on String { extension ImagesHolderStringExt on String {
@ -30,7 +43,3 @@ extension ImagesHolderStringExt on String {
extension ImagesHolderCardExt on GameCardDto { extension ImagesHolderCardExt on GameCardDto {
MemoryImage? get memoryImage => locator.imagesHolder.get(id.toString()); MemoryImage? get memoryImage => locator.imagesHolder.get(id.toString());
} }
extension ImagesHolderIntExt on int {
MemoryImage? get memoryImage => locator.imagesHolder.get(toString());
}

View file

@ -120,8 +120,10 @@ class PackCacheManager {
await _sharedPreferences.remove('packs'); await _sharedPreferences.remove('packs');
} }
Future<void> savePack(CardPackDto dto, Future<void> savePack(
{Map<String, MemoryImage>? images}) async { CardPackDto dto, {
Map<String, MemoryImage>? images,
}) async {
log('Saving pack ${dto.id}'); log('Saving pack ${dto.id}');
final dir = await _packDirectory(dto.id); final dir = await _packDirectory(dto.id);
final json = jsonEncode(dto); final json = jsonEncode(dto);

View file

@ -1,9 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'dart:developer'; 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/pack_cache_manager.dart';
import 'package:mnemo_cards/features/packs/packs_api.dart'; import 'package:mnemo_cards/features/packs/packs_api.dart';
import 'package:mnemo_cards/features/packs/preview_pack_holder.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/managers/repository/repository.dart';
import 'package:mnemo_cards/utils/iterable_helper.dart'; import 'package:mnemo_cards/utils/iterable_helper.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
@ -44,25 +46,43 @@ class PackManager {
// }); // });
} }
Stream<GetCardPackResponse> getCardPackStream(String id) async* { Stream<GetCardPackResponse> getCardPackStream(
String id,
bool isAvailable,
) async* {
if (isAvailable) {
try { try {
yield await _cacheManager.loadPackFromCache(id); yield await _cacheManager.loadPackFromCache(id);
} catch (err) { } catch (err) {
log(err.toString()); yield GetCardPackResponse.loading();
log('Cant load pack from cache, fetching pack'); log('Cant load pack from cache, fetching pack', error: err);
try { try {
final dto = await _packsApi.getPackDto(id).catchError((_) => null); await locator.packUpdater.updatePackAndSaveImages(id);
if (dto != null) { yield await _cacheManager.loadPackFromCache(id);
yield dto; } on Object catch (e, s) {
log('Cant fetch pack', error: e, stackTrace: s);
} finally {
log('Updating');
unawaited(locator.packUpdater.updateAvailablePacks());
}
}
} else { } else {
final buyDto = await _packsApi.getPackBuy(id); yield GetCardPackResponse.loading();
final buyDto = await _packsApi.getPackBuy(id).catchError((_) => null);
if (buyDto != null) { if (buyDto != null) {
yield buyDto; 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());
} }
} }
} catch (err) {
log(err.toString());
}
} }
yield* _packHolder.asStream yield* _packHolder.asStream
.map((event) => .map((event) =>
@ -73,6 +93,7 @@ class PackManager {
Future<void> clearCache() async { Future<void> clearCache() async {
_packHolder.clear(); _packHolder.clear();
await _cacheManager.clearCache(); await _cacheManager.clearCache();
await globalSharedPreferences.clear();
} }
Stream<List<CardPackDto>?> get packsStream => Stream<List<CardPackDto>?> get packsStream =>

View file

@ -34,7 +34,7 @@ class PackUpdater {
await updateAvailablePacks(); await updateAvailablePacks();
try { try {
final r = await _packsApi.getPacksPreviews(null); final r = await _packsApi.getPacksPreviews(null);
_previewPackHolder.setPreviewPacks(r); _previewPackHolder.setPreviewPacks(r, save: true);
} catch (e) { } catch (e) {
log(e.toString()); log(e.toString());
} }
@ -79,7 +79,7 @@ class PackUpdater {
log('UPDATE ${action.id}'); log('UPDATE ${action.id}');
shouldUpdateHolder = true; shouldUpdateHolder = true;
try { try {
final updatedPack = await _updatePackAndSaveImages(action.id); final updatedPack = await updatePackAndSaveImages(action.id);
updatedPacks[action.id] = updatedPack; updatedPacks[action.id] = updatedPack;
} catch (e) { } catch (e) {
log('Error while download cards for ${action.id} $e'); log('Error while download cards for ${action.id} $e');
@ -117,7 +117,7 @@ class PackUpdater {
return false; return false;
} }
Future<CardPackDto> _updatePackAndSaveImages(String packId) async { Future<CardPackDto> updatePackAndSaveImages(String packId) async {
try { try {
log('Updating with cards ${packId}', name: 'updatePackWithCards'); log('Updating with cards ${packId}', name: 'updatePackWithCards');
final packDto = await _packsApi.getPackDto(packId); final packDto = await _packsApi.getPackDto(packId);

View file

@ -1,7 +1,12 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:rxdart/rxdart.dart'; import 'package:rxdart/rxdart.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../main.dart';
class PreviewPackHolder { class PreviewPackHolder {
final StreamController<PreviewPackState?> _streamController = final StreamController<PreviewPackState?> _streamController =
@ -10,8 +15,52 @@ class PreviewPackHolder {
PreviewPackHolder(); PreviewPackHolder();
void setPreviewPacks(List<CardPackPreviewDto> packs) { Future<void> init() async {
if (_state == null) {
await loadFromCache();
}
}
Future<void> loadFromCache() async {
final sp = globalSharedPreferences;
final previewPacksStrings = sp.getStringList('preview_packs');
if (previewPacksStrings == null || previewPacksStrings.isEmpty) {
return;
}
final List<CardPackPreviewDto> previewPacks = [];
previewPacksStrings.forEach((string) {
try {
final pack = CardPackPreviewDto.fromJson(
jsonDecode(string) as Map<String, Object?>,
);
previewPacks.add(pack);
} catch (e, s) {
log('Cant load preview pack', error: e, stackTrace: s);
}
});
setPreviewPacks(previewPacks);
}
Future<void> savePreviewPacks() async {
final packs = state?.previewPacks ?? [];
final stringsList = <String>[];
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<CardPackPreviewDto> packs, {bool save = false}) {
_state = PreviewPackState(packs); _state = PreviewPackState(packs);
if (save) {
savePreviewPacks();
}
_streamController.add(_state); _streamController.add(_state);
} }

View file

@ -22,7 +22,7 @@ class PreviewPacksPoller {
try { try {
polling = true; polling = true;
final r = await _api.getPacksPreviews(null); final r = await _api.getPacksPreviews(null);
_previewPackHolder.setPreviewPacks(r); _previewPackHolder.setPreviewPacks(r, save: true);
} catch (e) { } catch (e) {
log(e.toString()); log(e.toString());
} }

View file

@ -10,6 +10,7 @@ import 'package:webview_flutter/webview_flutter.dart';
import '../../di/injector.dart'; import '../../di/injector.dart';
import '../../di/locator.dart'; import '../../di/locator.dart';
import '../../main.dart'; import '../../main.dart';
import '../analytics/analytics.dart';
enum StoreItemType { enum StoreItemType {
ads, ads,
@ -25,15 +26,34 @@ class InAppPurchaseService {
InAppPurchase get instance => _inAppPurchase; InAppPurchase get instance => _inAppPurchase;
Future<bool> buyPack(CardPackBuyDto dto) async { Future<bool> buyPack(CardPackBuyDto dto) async {
try {
final pack = await getPackStoreProduct(dto); final pack = await getPackStoreProduct(dto);
final result = await buyItemInStore(pack.first); final result = await buyItemInStore(pack.first);
return result; 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<bool> buyPackWithYooMoney(CardPackBuyDto dto, BuildContext? context) async { Future<bool> buyPackWithYooMoney(
CardPackBuyDto dto, BuildContext? context) async {
final paymentUrl = final paymentUrl =
await locator.repository.createPayment(dto.id, PaymentSystem.yookassa); await locator.repository.createPayment(dto.id, PaymentSystem.yookassa);
if (paymentUrl == null) { if (paymentUrl == null) {
Analytics.buyError(
dto,
PaymentSystem.yookassa,
message: 'payment url is null',
);
return false; return false;
} }
final controller = WebViewController() final controller = WebViewController()
@ -57,9 +77,18 @@ class InAppPurchaseService {
).then((_) async { ).then((_) async {
final success = await locator.repository.checkUserPayment(); final success = await locator.repository.checkUserPayment();
if (success) { if (success) {
Analytics.buyComplete(
dto,
PaymentSystem.yookassa,
);
locator.packUpdater.updateAvailablePacks(); locator.packUpdater.updateAvailablePacks();
return true; return true;
} }
Analytics.buyError(
dto,
PaymentSystem.yookassa,
message: 'payment not verified',
);
return false; return false;
}); });
} }
@ -67,6 +96,11 @@ class InAppPurchaseService {
Future<List<ProductDetails>> getPackStoreProduct(CardPackBuyDto dto) async { Future<List<ProductDetails>> getPackStoreProduct(CardPackBuyDto dto) async {
final bool isAvailable = await _inAppPurchase.isAvailable(); final bool isAvailable = await _inAppPurchase.isAvailable();
if (!isAvailable) { if (!isAvailable) {
Analytics.buyError(
dto,
PaymentSystem.google,
message: 'in app purchase not available',
);
return []; return [];
} }
final ids = { final ids = {
@ -74,6 +108,11 @@ class InAppPurchaseService {
if (Platform.isIOS) dto.appStoreId, if (Platform.isIOS) dto.appStoreId,
}.whereNotNull().toSet(); }.whereNotNull().toSet();
if (ids.isEmpty) { if (ids.isEmpty) {
Analytics.buyError(
dto,
PaymentSystem.google,
message: 'product id not set',
);
return []; return [];
} }
@ -82,6 +121,14 @@ class InAppPurchaseService {
if (productDetailResponse.error != null || if (productDetailResponse.error != null ||
productDetailResponse.productDetails.isEmpty) { productDetailResponse.productDetails.isEmpty) {
Analytics.buyError(
dto,
PaymentSystem.google,
data: {
'msg': 'cant get product details ${productDetailResponse.error}',
'not_found': productDetailResponse.notFoundIDs.join(','),
},
);
return []; return [];
} }
@ -104,14 +151,34 @@ class PurchaseDetailsStreamSubscription {
Future<void> onPurchased(PurchaseDetails purchaseDetails) async { Future<void> onPurchased(PurchaseDetails purchaseDetails) async {
try { 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( final result = await locator.repository.checkPayment(
purchaseDetails.productID, purchaseDetails.productID,
purchaseDetails.verificationData.serverVerificationData, purchaseDetails.verificationData.serverVerificationData,
PaymentSystem.google, PaymentSystem.google,
); );
if (result) { if (result) {
Analytics.buyComplete(
null,
PaymentSystem.google,
message: purchaseDetails.productID,
);
log('Purchased'); log('Purchased');
} else { } else {
Analytics.buyError(
null,
PaymentSystem.yookassa,
message: 'payment not verified ${purchaseDetails.productID}',
);
throw Exception('Cant verify purchase'); throw Exception('Cant verify purchase');
} }
} catch (e, s) { } catch (e, s) {

View file

@ -11,7 +11,7 @@ part 'input_buttons_test_state.g.dart';
@CopyWith() @CopyWith()
class InputButtonsTestState extends TestQuestionState { class InputButtonsTestState extends TestQuestionState {
final List<String> answer; final List<String> answer;
final List<int> answerIndexes; final List<String> answerIds;
final bool isCorrect; final bool isCorrect;
final bool isAnswered; final bool isAnswered;
final int seed; final int seed;
@ -19,7 +19,7 @@ class InputButtonsTestState extends TestQuestionState {
InputButtonsTestState({ InputButtonsTestState({
required this.seed, required this.seed,
this.answer = const [], this.answer = const [],
this.answerIndexes = const [], this.answerIds = const [],
this.isCorrect = false, this.isCorrect = false,
this.isAnswered = false, this.isAnswered = false,
super.testType = TestQuestionType.simple, super.testType = TestQuestionType.simple,

View file

@ -11,7 +11,7 @@ abstract class _$InputButtonsTestStateCWProxy {
InputButtonsTestState answer(List<String> answer); InputButtonsTestState answer(List<String> answer);
InputButtonsTestState answerIndexes(List<int> answerIndexes); InputButtonsTestState answerIds(List<String> answerIds);
InputButtonsTestState isCorrect(bool isCorrect); InputButtonsTestState isCorrect(bool isCorrect);
@ -28,7 +28,7 @@ abstract class _$InputButtonsTestStateCWProxy {
InputButtonsTestState call({ InputButtonsTestState call({
int? seed, int? seed,
List<String>? answer, List<String>? answer,
List<int>? answerIndexes, List<String>? answerIds,
bool? isCorrect, bool? isCorrect,
bool? isAnswered, bool? isAnswered,
TestQuestionType? testType, TestQuestionType? testType,
@ -49,8 +49,8 @@ class _$InputButtonsTestStateCWProxyImpl
InputButtonsTestState answer(List<String> answer) => this(answer: answer); InputButtonsTestState answer(List<String> answer) => this(answer: answer);
@override @override
InputButtonsTestState answerIndexes(List<int> answerIndexes) => InputButtonsTestState answerIds(List<String> answerIds) =>
this(answerIndexes: answerIndexes); this(answerIds: answerIds);
@override @override
InputButtonsTestState isCorrect(bool isCorrect) => this(isCorrect: isCorrect); InputButtonsTestState isCorrect(bool isCorrect) => this(isCorrect: isCorrect);
@ -74,7 +74,7 @@ class _$InputButtonsTestStateCWProxyImpl
InputButtonsTestState call({ InputButtonsTestState call({
Object? seed = const $CopyWithPlaceholder(), Object? seed = const $CopyWithPlaceholder(),
Object? answer = const $CopyWithPlaceholder(), Object? answer = const $CopyWithPlaceholder(),
Object? answerIndexes = const $CopyWithPlaceholder(), Object? answerIds = const $CopyWithPlaceholder(),
Object? isCorrect = const $CopyWithPlaceholder(), Object? isCorrect = const $CopyWithPlaceholder(),
Object? isAnswered = const $CopyWithPlaceholder(), Object? isAnswered = const $CopyWithPlaceholder(),
Object? testType = const $CopyWithPlaceholder(), Object? testType = const $CopyWithPlaceholder(),
@ -88,11 +88,10 @@ class _$InputButtonsTestStateCWProxyImpl
? _value.answer ? _value.answer
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
: answer as List<String>, : answer as List<String>,
answerIndexes: answerIds: answerIds == const $CopyWithPlaceholder() || answerIds == null
answerIndexes == const $CopyWithPlaceholder() || answerIndexes == null ? _value.answerIds
? _value.answerIndexes
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
: answerIndexes as List<int>, : answerIds as List<String>,
isCorrect: isCorrect == const $CopyWithPlaceholder() || isCorrect == null isCorrect: isCorrect == const $CopyWithPlaceholder() || isCorrect == null
? _value.isCorrect ? _value.isCorrect
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
@ -129,8 +128,8 @@ InputButtonsTestState _$InputButtonsTestStateFromJson(
?.map((e) => e as String) ?.map((e) => e as String)
.toList() ?? .toList() ??
const [], const [],
answerIndexes: (json['answerIndexes'] as List<dynamic>?) answerIds: (json['answerIds'] as List<dynamic>?)
?.map((e) => (e as num).toInt()) ?.map((e) => e as String)
.toList() ?? .toList() ??
const [], const [],
isCorrect: json['isCorrect'] as bool? ?? false, isCorrect: json['isCorrect'] as bool? ?? false,
@ -145,7 +144,7 @@ Map<String, dynamic> _$InputButtonsTestStateToJson(
<String, dynamic>{ <String, dynamic>{
'testType': _$TestQuestionTypeEnumMap[instance.testType]!, 'testType': _$TestQuestionTypeEnumMap[instance.testType]!,
'answer': instance.answer, 'answer': instance.answer,
'answerIndexes': instance.answerIndexes, 'answerIds': instance.answerIds,
'isCorrect': instance.isCorrect, 'isCorrect': instance.isCorrect,
'isAnswered': instance.isAnswered, 'isAnswered': instance.isAnswered,
'seed': instance.seed, 'seed': instance.seed,

View file

@ -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_state_holder.dart';
import 'package:mnemo_cards/features/tests/test_widgets/test_image.dart'; import 'package:mnemo_cards/features/tests/test_widgets/test_image.dart';
import 'package:mnemo_cards/features/packs/pack_cache_manager.dart'; import 'package:mnemo_cards/features/packs/pack_cache_manager.dart';
import 'package:mnemo_cards/main.dart';
import 'package:mnemo_cards/theme/themes.dart'; import 'package:mnemo_cards/theme/themes.dart';
import 'package:mnemo_cards/utils/iterable_helper.dart'; import 'package:mnemo_cards/utils/iterable_helper.dart';
import 'package:mnemo_cards/utils/string_helper.dart'; import 'package:mnemo_cards/utils/string_helper.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:rxdart/rxdart.dart';
import '../../managers/repository/http_repository.dart'; import '../../managers/repository/http_repository.dart';
import 'question_states/test_question_state.dart'; import 'question_states/test_question_state.dart';
@ -28,6 +30,18 @@ class TestManager extends ChangeNotifier {
return tests; return tests;
} }
Stream<List<TestDto>> loadTestsStream({String? packId}) {
return Stream.periodic(
Duration(seconds: 60),
)
.startWith(null)
.asyncMap(
(tests) async => loadTests(packId: packId)
.catchError((_) => Future.value(<TestDto>[])),
)
.where((test) => test.isNotEmpty);
}
TestQuestionState? getState(int id) => testHolder.testQuestionState(id); TestQuestionState? getState(int id) => testHolder.testQuestionState(id);
Iterable<TestQuestionState> get states => testHolder.state.questions.values; Iterable<TestQuestionState> get states => testHolder.state.questions.values;
@ -37,7 +51,15 @@ class TestManager extends ChangeNotifier {
if (packId != null) { if (packId != null) {
await packCacheManager.loadPackFromCache(packId); 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.clear();
questions.addAll(test!.questions); questions.addAll(test!.questions);
color = test.color?.asColor ?? borderGray; color = test.color?.asColor ?? borderGray;

View file

@ -38,7 +38,7 @@ class _TestPageState extends State<TestPage> {
if (!s.hasData || s.requireData!.questions.isEmpty) { if (!s.hasData || s.requireData!.questions.isEmpty) {
return Scaffold( return Scaffold(
body: Center( body: Center(
child: Text('Loading'), child: Text('Загружаю тест...'),
), ),
); );
} }

View file

@ -1,106 +1,64 @@
import 'dart:math'; import 'dart:math';
import 'package:collection/collection.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:mnemo_cards/features/tests/test_manager.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/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:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:collection/collection.dart';
import '../../../di/locator.dart'; import '../../../di/locator.dart';
import '../../../widgets/game_card_widget.dart'; import '../../../widgets/game_card_widget.dart';
import '../question_states/input_buttons_test_state.dart'; import '../question_states/input_buttons_test_state.dart';
import 'test_button.dart'; import 'test_button.dart';
class InputButtonsTestWidget extends StatelessWidget { class InputButtonsTestWidget extends StatelessWidget {
final InputButtonsTestQuestionBody model;
bool get isCorrect => state.answer.join() == model.answer; bool get isCorrect => state.answer.join() == model.answer;
final InputButtonsTestQuestionBody _inputButtonModel;
InputButtonsTestQuestionBody get model => _inputButtonModel;
InputButtonsTestState get state => InputButtonsTestState get state =>
manager.getState(model.id!) as InputButtonsTestState; manager.getState(model.id!) as InputButtonsTestState;
TestManager get manager => locator.testManager; TestManager get manager => locator.testManager;
InputButtonsTestWidget(this.model, {super.key}); Color get testColor => manager.color;
Widget _letter(BuildContext context, String text) => Padding( InputButtonsTestWidget(this._inputButtonModel, {super.key});
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,
),
),
),
);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context).textTheme; final theme = Theme.of(context).textTheme;
final random = Random(state.seed); // final random = Random(state.seed);
final buttons = model.buttons..shuffle(random); final buttons = model.buttons; //..shuffle(random);
return LayoutBuilder(builder: (context, constraints) {
return Padding( return Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Column( child: LayoutBuilder(builder: (context, constraints) {
return Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Flexible( if (model.image != null)
flex: 2, Container(
child: AspectRatio(
aspectRatio: 1,
child: Container(
margin: const EdgeInsets.all(4.0), margin: const EdgeInsets.all(4.0),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: borderRadius:
BorderRadius.circular(GameCardWidget.borderRadius), BorderRadius.circular(16.0),
), ),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
if (model.image != null) AspectRatio(
TestImageWidget( aspectRatio: 1,
child: TestImageWidget(
TestImage.image(model.image!), TestImage.image(model.image!),
), ),
),
if (model.text != null) if (model.text != null)
Padding( Container(
padding: const EdgeInsets.only(top: 8.0), padding: EdgeInsets.only(top: 8.0),
child: Center( child: Center(
child: Text( child: Text(
model.text!, model.text!,
@ -110,20 +68,34 @@ class InputButtonsTestWidget extends StatelessWidget {
), ),
], ],
), ),
)
else Flexible(
child: Center(
child: Text(
model.text!,
style: theme.headlineLarge,
), ),
), ),
), ),
StatefulBuilder(builder: (context, setState) { 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) >= if (state.answer.fold(0, (p, n) => p + n.length) >=
model.answer.length) { model.answer.length) {
return; return;
} }
setState(() { 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( manager.setState(
model.id!, model.id!,
state.copyWith( state.copyWith(
answer: [...state.answer, p.$2.text!], answer: answer,
answerIds: [...state.answerIds, button.id],
), ),
); );
manager.setState( 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( return Expanded(
child: Column( child: Column(
children: [ children: [
Expanded( Expanded(
child: GestureDetector( child: GestureDetector(
onTap: () { 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(() { setState(() {
manager.setState( manager.setState(
model.id!, model.id!,
state.copyWith( state.copyWith(
isAnswered: false, // isAnswered: false,
isCorrect: false, // isCorrect: false,
answer: [], answer: answer,
answerIndexes: [], answerIds: answerIds,
), ),
); );
}); });
}, },
child: Row( child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ mainAxisSize: MainAxisSize.min,
...state.answer.map((e) => _letter(context, e)), children: rows,
...model.answer
.substring(
state.answer.fold(0, (p, n) => p + n.length),
)
.characters
.map((e) => _emptyCell(context)),
],
), ),
), ),
), ),
@ -181,17 +209,20 @@ class InputButtonsTestWidget extends StatelessWidget {
AbsorbPointer( AbsorbPointer(
absorbing: state.isCorrect, absorbing: state.isCorrect,
child: Wrap( child: Wrap(
spacing: 8.0,
runSpacing: 8.0,
children: [ children: [
...buttons.indexed.map( ...buttons.mapIndexed(
(p) => Container( (index, button) => Container(
width: p.$2.text!.length > 3 ? null : 80, margin: EdgeInsets.all(4.0),
height: 60, width: button.text!.length > 3
child: TestButton( ? null
text: p.$2.text, : constraints.maxWidth / 6,
onTap: () => buttonTapped(p), height: 50,
color: state.answer == p.$2 child: state.answerIds.contains(button.id)
? SizedBox.shrink()
: TestButton(
text: button.text,
onTap: () => buttonTapped(index, button),
color: state.answer == button
? isCorrect ? isCorrect
? Colors.green[200] ? Colors.green[200]
: Colors.red[200] : Colors.red[200]
@ -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,
),
), ),
); );
});
} }
} }

View file

@ -1,3 +1,2 @@
import 'package:flutter/foundation.dart';
const ADMIN_BUILD = true; const ADMIN_BUILD = false;

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'dart:io'; import 'dart:io';
@ -5,7 +6,9 @@ import 'package:auto_route/annotations.dart';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:jailbreak_root_detection/jailbreak_root_detection.dart'; import 'package:jailbreak_root_detection/jailbreak_root_detection.dart';
import 'package:mnemo_cards/di/locator.dart'; import 'package:mnemo_cards/di/locator.dart';
@ -26,7 +29,7 @@ import 'managers/repository/repository.dart';
final scaffoldKey = GlobalKey<ScaffoldState>(); final scaffoldKey = GlobalKey<ScaffoldState>();
final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>(); final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
SharedPreferences? globalSharedPreferences; late final SharedPreferences globalSharedPreferences;
final appRouter = AppRouter(ADMIN_BUILD); final appRouter = AppRouter(ADMIN_BUILD);
@ -37,28 +40,49 @@ Future<bool> _screenOff() async =>
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
final stopwatch = Stopwatch()..start();
print('ensureInitialized ${stopwatch.elapsedMilliseconds}');
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
]);
androidDeviceInfo = await DeviceInfoPlugin().androidInfo; androidDeviceInfo = await DeviceInfoPlugin().androidInfo;
print('deviceInfo ${stopwatch.elapsedMilliseconds}');
final isNotTrust = await JailbreakRootDetection.instance.isNotTrust; final isNotTrust = await JailbreakRootDetection.instance.isNotTrust;
print('No trunst ${stopwatch.elapsedMilliseconds}');
if (isNotTrust && !ADMIN_BUILD) { if (isNotTrust && !ADMIN_BUILD) {
print('Not trusted'); print('Not trusted');
return; return;
} }
final off = await _screenOff(); final off = await _screenOff();
print('screenshot off ${stopwatch.elapsedMilliseconds}');
if (!off) { if (!off) {
print('Cant disable screenhsot'); print('Cant disable screenhsot');
return; return;
} }
FlutterError.onError = (errorDetails) {
FirebaseCrashlytics.instance.recordFlutterFatalError(errorDetails);
};
try { try {
globalSharedPreferences = await SharedPreferences.getInstance(); globalSharedPreferences = await SharedPreferences.getInstance();
print('global sp ${stopwatch.elapsedMilliseconds}');
await setInjections(); await setInjections();
await initFirebase(); print('injections ${stopwatch.elapsedMilliseconds}');
unawaited(initFirebase());
print('initing... ${stopwatch.elapsedMilliseconds}');
await locator.packCacheManager.init(); await locator.packCacheManager.init();
await locator.previewPackHolder.init();
await locator.previewPackPoller.init();
await locator.packManager.init(); await locator.packManager.init();
await locator.userManager.init(); await locator.userManager.init();
await locator.favoriteCardsController.init(); await locator.favoriteCardsController.init();
await locator.previewPackPoller.init(); await locator.previewPackPoller.init();
await locator.packUpdater.init(); await locator.packUpdater.init();
print('initing complete ${stopwatch.elapsedMilliseconds}');
await MobileAds.initialize(); await MobileAds.initialize();
print('mobile ads ${stopwatch.elapsedMilliseconds}');
print('runApp ${stopwatch.elapsedMilliseconds}');
runApp(const MyApp()); runApp(const MyApp());
} on Object catch (e, s) { } on Object catch (e, s) {
log(e.toString(), stackTrace: s); log(e.toString(), stackTrace: s);
@ -70,7 +94,7 @@ Future<void> initFirebase() async {
try { try {
await Firebase.initializeApp( await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform, options: DefaultFirebaseOptions.currentPlatform,
).timeout(Duration(seconds: 2)); );
await FirebaseRepository.update(); await FirebaseRepository.update();
} on Object catch (e, s) { } on Object catch (e, s) {
log('Firebase not inited', error: e, stackTrace: s); log('Firebase not inited', error: e, stackTrace: s);

View file

@ -22,7 +22,7 @@ class FavoriteCardsController {
} }
List<String> get state { List<String> get state {
state = (_sharedPreferences.getStringList('favorites') ?? []); _state = (_sharedPreferences.getStringList('favorites') ?? []);
return _state; return _state;
} }

View file

@ -1,12 +1,13 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:mnemo_cards/flags.dart';
import '../../main.dart'; import '../../main.dart';
import 'firebase_config_repository.dart'; import 'firebase_config_repository.dart';
mixin Api { mixin Api {
String get path => String get path =>
kReleaseMode || (globalSharedPreferences?.getBool('env') ?? true) !ADMIN_BUILD || (globalSharedPreferences.getBool('env') ?? true)
//prod //prod
? 'https://89.19.210.178:8080' //FirebaseRepository.appConfig.path ? 'https://89.19.210.178:8080' //FirebaseRepository.appConfig.path
: androidDeviceInfo!.isPhysicalDevice : androidDeviceInfo!.isPhysicalDevice

View file

@ -15,6 +15,7 @@ import 'package:device_info_plus/device_info_plus.dart';
import '../domain/router/app_router.dart'; import '../domain/router/app_router.dart';
import '../domain/router/app_router.gr.dart'; import '../domain/router/app_router.gr.dart';
import '../features/analytics/analytics.dart';
import 'user_state_holder.dart'; import 'user_state_holder.dart';
class UserManager { class UserManager {
@ -48,6 +49,7 @@ class UserManager {
if (await _googleSignIn.isSignedIn()) { if (await _googleSignIn.isSignedIn()) {
await _googleSignIn.signOut(); await _googleSignIn.signOut();
} }
Analytics.logout(userStateHolder.user);
userStateHolder.clear(); userStateHolder.clear();
} }
@ -56,9 +58,10 @@ class UserManager {
ExternalIdType idType, ExternalIdType idType,
String? name, String? name,
) async { ) async {
final p = await _repository.createOrGetUser(externalId, idType, name); final (user, token) =
_setAuthToken(p.$2); await _repository.createOrGetUser(externalId, idType, name);
userStateHolder.setUser(p.$1); _setAuthToken(token);
userStateHolder.setUser(user);
} }
void _setAuthToken(String authToken) async { void _setAuthToken(String authToken) async {
@ -111,6 +114,7 @@ class UserManager {
final name = account.displayName; final name = account.displayName;
final token = final token =
await account.authentication.then((value) => value.idToken); await account.authentication.then((value) => value.idToken);
Analytics.login(ExternalIdType.google);
await _createUser(token!, ExternalIdType.google, name); await _createUser(token!, ExternalIdType.google, name);
} }
})) }))

View file

@ -4,6 +4,8 @@ import 'package:flutter/widgets.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:rxdart/rxdart.dart'; import 'package:rxdart/rxdart.dart';
import '../features/analytics/analytics.dart';
class UserStateHolder extends ChangeNotifier { class UserStateHolder extends ChangeNotifier {
UserDto? _dto; UserDto? _dto;
@ -17,6 +19,8 @@ class UserStateHolder extends ChangeNotifier {
void setUser(UserDto dto) { void setUser(UserDto dto) {
_dto = dto.copyWith(); _dto = dto.copyWith();
Analytics.setUser(dto);
notifyListeners(); notifyListeners();
_streamController.add(_dto); _streamController.add(_dto);
} }

View file

@ -13,6 +13,7 @@ import 'package:url_launcher/url_launcher.dart';
import '../admin/all_cards.dart'; import '../admin/all_cards.dart';
import '../di/locator.dart'; import '../di/locator.dart';
import '../features/analytics/analytics.dart';
import '../theme/themes.dart'; import '../theme/themes.dart';
import '../widgets/shared_pref_button.dart'; import '../widgets/shared_pref_button.dart';
import '../widgets/text_button.dart'; import '../widgets/text_button.dart';
@ -21,6 +22,7 @@ import '../widgets/text_button.dart';
class ProfilePage extends StatelessWidget { class ProfilePage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Analytics.profilePageOpened(locator.userManager.userStateHolder.user);
final theme = Theme.of(context).textTheme; final theme = Theme.of(context).textTheme;
return Scaffold( return Scaffold(
body: SafeArea( body: SafeArea(
@ -30,7 +32,7 @@ class ProfilePage extends StatelessWidget {
stream: locator.userManager.userStateHolder.asStream, stream: locator.userManager.userStateHolder.asStream,
builder: (context, snapshot) { builder: (context, snapshot) {
var title = snapshot.data?.name; var title = snapshot.data?.name;
if (title == null || title.length > 10) { if (true || title == null || title.length > 10) {
title = 'Привет!'; title = 'Привет!';
} }
return Header( return Header(
@ -63,7 +65,8 @@ class ProfilePage extends StatelessWidget {
if (ADMIN_BUILD) if (ADMIN_BUILD)
MaterialButton( MaterialButton(
onPressed: () { onPressed: () {
showDialog(context: context, builder: (c) => const AllCards()); showDialog(
context: context, builder: (c) => const AllCards());
}, },
child: Text('ALL CARDS'), child: Text('ALL CARDS'),
), ),

View file

@ -1,19 +1,12 @@
import 'dart:math'; import 'dart:math';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:mnemo_cards/features/packs/images_holder.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/color_helper.dart';
import 'package:mnemo_cards/utils/string_helper.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:rxdart/rxdart.dart'; import '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_flipper/flip_card_controllers.dart';
import '../../features/card_swiper/src/allowed_swipe_direction.dart'; import '../../features/card_swiper/src/allowed_swipe_direction.dart';
import '../../features/card_swiper/src/card_swiper.dart'; import '../../features/card_swiper/src/card_swiper.dart';
@ -22,14 +15,16 @@ import '../../main.dart';
import '../game_card_widget.dart'; import '../game_card_widget.dart';
class CardGameMainWidget extends StatelessWidget { class CardGameMainWidget extends StatelessWidget {
final CardPackDto cardPack; final List<GameCardDto> cards;
final Color? color;
final int? startCardId; final int? startCardId;
final CardSwiperController cardSwiperController; final CardSwiperController cardSwiperController;
final FlipCardController flipCardController; final FlipCardController flipCardController;
final int length; final int length;
const CardGameMainWidget( const CardGameMainWidget(
this.cardPack, this.cards,
this.color,
this.startCardId, this.startCardId,
this.cardSwiperController, this.cardSwiperController,
this.flipCardController, this.flipCardController,
@ -38,23 +33,9 @@ class CardGameMainWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final numberOfCardsDisplayed = min(2, cardPack.cards.length); final numberOfCardsDisplayed = min(2, cards.length);
final initialScale = 0.9; final initialScale = 0.9;
int lastThreshold = 0;
List<GameCardDto> 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 GameCardDto? activeCard() => cardSwiperController.currentIndex >= 0
? cards[cardSwiperController.currentIndex % cards.length] ? cards[cardSwiperController.currentIndex % cards.length]
: null; : null;
@ -75,12 +56,7 @@ class CardGameMainWidget extends StatelessWidget {
return StatefulBuilder(builder: (context, setState) { return StatefulBuilder(builder: (context, setState) {
return Stack( return Stack(
children: [ children: [
_EndWidget(cardSwiperController, flipCardController, () { _EndWidget(),
setState(() {
enabled = false;
});
AutoRouter.of(context).maybePop();
}),
if (enabled) if (enabled)
Hero( Hero(
tag: activeCard()?.id ?? 'empty', tag: activeCard()?.id ?? 'empty',
@ -127,21 +103,21 @@ class CardGameMainWidget extends StatelessWidget {
cardIndex); cardIndex);
if (cardSwiperController.currentIndex == if (cardSwiperController.currentIndex ==
index) { index) {
lastThreshold = percentThresholdX; // lastThreshold = percentThresholdX;
} }
print('${cards[index].translation} $scale'); print('${cards[index].translation} $scale');
final cardWidget = Center( final cardWidget = Center(
child: GameCardWidget( child: GameCardWidget(
cards[index], cards[index],
cards[index].id.memoryImage, cards[index].memoryImage,
flipCardController, flipCardController,
flipCardController.state flipCardController.state
// || cards[index].isAd // || cards[index].isAd
, ,
isFirstCard, isFirstCard,
isFirstCard isFirstCard
? cardPack.color?.asColor ? color
: cardPack.color?.asColor?.darken( : color?.darken(
(scale - 1) / (scale - 1) /
(initialScale - 1) * (initialScale - 1) *
0.075, 0.075,
@ -164,38 +140,10 @@ class CardGameMainWidget extends StatelessWidget {
} }
class _EndWidget extends StatelessWidget { class _EndWidget extends StatelessWidget {
final CardSwiperController cardSwiperController; _EndWidget();
final FlipCardController flipCardController;
final VoidCallback callback;
_EndWidget(
this.cardSwiperController,
this.flipCardController,
this.callback,
);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Center( return Container();
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'),
),
],
),
);
} }
} }

View file

@ -1,18 +1,13 @@
import 'dart:math'; import 'dart:math';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:mnemo_cards/features/tests/progress_widget.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/theme/themes.dart';
import 'package:mnemo_cards/utils/string_helper.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:rxdart/rxdart.dart'; import 'package:rxdart/rxdart.dart';
import '../../di/locator.dart'; import '../../di/locator.dart';
import '../../features/card_flipper/flip_card.dart';
import '../../features/card_flipper/flip_card_controllers.dart'; import '../../features/card_flipper/flip_card_controllers.dart';
import '../../features/card_swiper/flutter_card_swiper.dart'; import '../../features/card_swiper/flutter_card_swiper.dart';
import 'card_game_main.dart'; import 'card_game_main.dart';
@ -20,13 +15,17 @@ import 'card_game_main.dart';
@RoutePage() @RoutePage()
class CardGamePage extends StatefulWidget { class CardGamePage extends StatefulWidget {
final int? startCardId; final int? startCardId;
final CardPackDto cardPack; final List<GameCardDto> cards;
final Color? color;
final bool infinite; final bool infinite;
final bool shuffle;
const CardGamePage({ const CardGamePage({
required this.cardPack, required this.cards,
required this.color,
this.startCardId, this.startCardId,
this.infinite = false, this.infinite = false,
this.shuffle = true,
super.key, super.key,
}); });
@ -38,17 +37,17 @@ class _CardGamePageState extends State<CardGamePage> {
final CardSwiperController cardSwiperController = CardSwiperController(); final CardSwiperController cardSwiperController = CardSwiperController();
final FlipCardController flipCardController = FlipCardController(); final FlipCardController flipCardController = FlipCardController();
List<GameCardDto> shuffledCards = [];
GameCardDto? get activeCard { GameCardDto? get activeCard {
final index = cardSwiperController.currentIndex; final index = cardSwiperController.currentIndex;
final cards = widget.cardPack.cards;
if (index >= 0) { if (index >= 0) {
return cards[index % cards.length]; return shuffledCards[index % shuffledCards.length];
} }
return null; return null;
} }
int get gameLength => int get gameLength => widget.infinite ? 10000000 : shuffledCards.length;
widget.infinite ? 10000000 : widget.cardPack.cards.length;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -95,9 +94,8 @@ class _CardGamePageState extends State<CardGamePage> {
cardSwiperController.progress, cardSwiperController.progress,
Result.correct, Result.correct,
), ),
widget.cardPack.cards.length, shuffledCards.length,
widget.cardPack.color?.asColor ?? widget.color ?? borderGray,
borderGray,
timer: timer, timer: timer,
popText: widget.infinite ? 'к теме' : null, popText: widget.infinite ? 'к теме' : null,
key: ValueKey('CARDS PROGRESS'), key: ValueKey('CARDS PROGRESS'),
@ -116,7 +114,8 @@ class _CardGamePageState extends State<CardGamePage> {
child: Stack( child: Stack(
children: [ children: [
CardGameMainWidget( CardGameMainWidget(
widget.cardPack, shuffledCards,
widget.color,
widget.startCardId, widget.startCardId,
cardSwiperController, cardSwiperController,
flipCardController, flipCardController,
@ -186,7 +185,12 @@ class _CardGamePageState extends State<CardGamePage> {
), ),
padding: EdgeInsets.all(10.0.w), padding: EdgeInsets.all(10.0.w),
child: Image.asset( child: Image.asset(
"icons/heart.png", locator.favoriteCardsController
.isFavoriteCard(
activeCard?.id,
)
? "icons/heart_fill.png"
: "icons/heart.png",
width: 29.w, width: 29.w,
height: 29.h, height: 29.h,
color: locator color: locator
@ -249,6 +253,31 @@ class _CardGamePageState extends State<CardGamePage> {
); );
} }
@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 @override
void dispose() { void dispose() {
// cardSwiperController.removeListener(cardSwiperListener); // cardSwiperController.removeListener(cardSwiperListener);

View file

@ -1,16 +1,20 @@
import 'dart:async';
import 'dart:math'; import 'dart:math';
import 'package:collection/collection.dart';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/animation.dart'; import 'package:flutter/animation.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:mnemo_cards/features/packs/images_holder.dart'; import 'package:mnemo_cards/features/packs/images_holder.dart';
import 'package:mnemo_cards/main.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:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:yandex_mobileads/mobile_ads.dart'; import 'package:yandex_mobileads/mobile_ads.dart';
import '../../domain/router/app_router.gr.dart'; import '../../domain/router/app_router.gr.dart';
import '../../features/analytics/analytics.dart';
import '../../features/yandex_ads/yandex_ads.dart'; import '../../features/yandex_ads/yandex_ads.dart';
import '../game_card_widget.dart'; import '../game_card_widget.dart';
@ -19,67 +23,113 @@ import '../../theme/themes.dart';
import 'card_pack_header.dart'; import 'card_pack_header.dart';
import 'package:mnemo_cards/utils/string_helper.dart'; import 'package:mnemo_cards/utils/string_helper.dart';
import 'cards_view.dart';
import 'cards_view_controller.dart';
final _mainScrollController = ScrollController();
class AvailablePack extends StatelessWidget { class AvailablePack extends StatelessWidget {
final CardPackDto cardPack; final CardPackDto cardPack;
final _CardsViewController _cardsViewController = _CardsViewController(); late final CardsViewController _cardsViewController =
CardsViewController(cardPack);
AvailablePack(this.cardPack); AvailablePack(this.cardPack);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Analytics.availablePackOpened(cardPack);
return Column( return Column(
children: [ children: [
CardPackHeader.fromDto(cardPack), CardPackHeader.fromDto(cardPack),
Expanded( Expanded(
child: ListView( child: ListView(
controller: _mainScrollController,
children: [ children: [
StatefulBuilder(builder: (context, setState) { StatefulBuilder(builder: (context, setState) {
return Column( return Column(
children: [ children: [
Container( Container(
padding: EdgeInsets.symmetric(vertical: 0.h),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border( color: cardPack.color?.asColor?.withOpacity(0.1),
bottom: BorderSide(
color: cardPack.color?.asColor ??
Colors.grey.withOpacity(0.5)),
),
), ),
alignment: Alignment.center,
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: _CardsView(_cardsViewController, cardPack), child: CardsView(_cardsViewController, cardPack),
),
Container(
height: 1,
color: cardPack.color?.asColor ??
Colors.grey.withOpacity(0.5),
), ),
SizedBox( SizedBox(
height: 10.h, height: 10.h,
), ),
Padding( Padding(
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
vertical: 8.0.h,
horizontal: 10.w, horizontal: 10.w,
), ),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
_Button('view.png', () async { StreamBuilder(
_cardsViewController.toggleExpanded(); 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 { _Button('shuffle.png', () async {
final index = final cards = _cardsViewController.state.packCards;
Random().nextInt(cardPack.cards.length); if (cards.isEmpty) return;
final index = (_cardsViewController.offsetCards +
Random().nextInt(
(cards.length / 2).ceil(),
) +
3) %
cards.length;
await _cardsViewController.animateTo(index); await _cardsViewController.animateTo(index);
AutoRouter.of(context).push( AutoRouter.of(context).push(
PageRouteInfo( PageRouteInfo(
CardGamePage.name, CardGamePage.name,
args: CardGamePageArgs( args: CardGamePageArgs(
cardPack: cardPack, cards: cards,
startCardId: cardPack.cards[index].id, color: cardPack.color?.asColor,
startCardId: cards[index].id,
infinite: true, infinite: true,
shuffle: true,
// id: cardPack.dto.id.toString(), // id: cardPack.dto.id.toString(),
), ),
), ),
); );
}), }),
// _Button('chat.png', () {}), // _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,14 +174,17 @@ class _TestsSection extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return FutureBuilder( return StreamBuilder(
future: locator.testManager.loadTests(packId: packId), stream: locator.testManager.loadTestsStream(packId: packId),
builder: (context, snapshot) { builder: (context, snapshot) {
return Column( return Column(
children: [ children: [
if (snapshot.hasData) if (snapshot.hasData)
...snapshot.data!.map( ...snapshot.data!.mapIndexed(
(dto) => _TestButton( (int index, TestDto dto) => FadeInWidget(
delay: Duration(milliseconds: index * 200),
animationDuration: const Duration(milliseconds: 200),
child: _TestButton(
title: dto.name, title: dto.name,
subtitle: null, subtitle: null,
time: dto.time, time: dto.time,
@ -144,6 +197,7 @@ class _TestsSection extends StatelessWidget {
}, },
), ),
), ),
),
], ],
); );
}, },
@ -152,12 +206,18 @@ class _TestsSection extends StatelessWidget {
} }
class _AdTile extends StatelessWidget { class _AdTile extends StatelessWidget {
const _AdTile(); const _AdTile({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context).textTheme; final theme = Theme.of(context).textTheme;
return Container( 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, height: 80,
margin: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0), margin: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0),
decoration: BoxDecoration( decoration: BoxDecoration(
@ -168,12 +228,11 @@ class _AdTile extends StatelessWidget {
borderRadius: BorderRadius.circular(12.0), borderRadius: BorderRadius.circular(12.0),
), ),
child: Center( child: Center(
child: LayoutBuilder(builder: (context, constraints) { child: AdWidget(bannerAd: banner),
return AdWidget( ),
bannerAd: YandexAds.createBanner(constraints)..loadAd());
}),
), ),
); );
});
} }
} }
@ -181,10 +240,7 @@ class _Button extends StatelessWidget {
final String asset; final String asset;
final VoidCallback onTap; final VoidCallback onTap;
_Button( _Button(this.asset, this.onTap, {super.key});
this.asset,
this.onTap,
);
@override @override
Widget build(BuildContext context) { 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<StatefulWidget> 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<void> 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,
);
}
}

View file

@ -1,15 +1,22 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:developer';
import 'package:auto_route/auto_route.dart';
import 'package:mnemo_cards/utils/string_helper.dart'; import 'package:mnemo_cards/utils/string_helper.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.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 '../items/builder.dart';
import '../../di/locator.dart'; import '../../di/locator.dart';
import '../../domain/router/app_router.dart'; import '../../domain/router/app_router.dart';
import '../../theme/themes.dart'; import '../../theme/themes.dart';
import 'card_pack_header.dart'; import 'card_pack_header.dart';
import 'loading_pack.dart';
class BuyPack extends StatelessWidget { class BuyPack extends StatelessWidget {
final CardPackBuyDto dto; final CardPackBuyDto dto;
@ -18,9 +25,15 @@ class BuyPack extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
bool loading = false;
Analytics.buyPackOpened(dto);
return Container( return Container(
// color: dto.color?.asColor, // color: dto.color?.asColor,
child: Column( child: StatefulBuilder(builder: (context, setState) {
if (loading) {
return _Loading(dto);
}
return Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
CardPackHeader(dto.title, dto.subtitle, dto.color?.asColor), CardPackHeader(dto.title, dto.subtitle, dto.color?.asColor),
@ -28,8 +41,8 @@ class BuyPack extends StatelessWidget {
child: Column( child: Column(
children: [ children: [
Padding( Padding(
padding: padding: EdgeInsets.symmetric(
EdgeInsets.symmetric(horizontal: 10.0.w, vertical: 18.h), horizontal: 10.0.w, vertical: 18.h),
child: LayoutBuilder(builder: (context, constraints) { child: LayoutBuilder(builder: (context, constraints) {
final cardWidth = constraints.maxWidth / 3; final cardWidth = constraints.maxWidth / 3;
final imageWidth = cardWidth * 0.9; final imageWidth = cardWidth * 0.9;
@ -72,20 +85,75 @@ class BuyPack extends StatelessWidget {
], ],
), ),
), ),
_BuyButton(dto), _BuyButton(dto, (v) {
setState(() {
loading = v;
});
}),
SizedBox( SizedBox(
height: 16.h, 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('Загрузка...'),
],
),
),
),
],
); );
} }
} }
class _BuyButton extends StatelessWidget { class _BuyButton extends StatelessWidget {
final CardPackBuyDto dto; final CardPackBuyDto dto;
final Function(bool) callback;
_BuyButton(this.dto); _BuyButton(this.dto, this.callback);
Future<void> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -101,7 +169,11 @@ class _BuyButton extends StatelessWidget {
InkWell( InkWell(
onTap: () async { onTap: () async {
if (locator.userManager.hasUser) { 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 { } else {
AppRouter.openAuth(); AppRouter.openAuth();
} }
@ -145,11 +217,15 @@ class _BuyButton extends StatelessWidget {
TextButton( TextButton(
style: ButtonStyle( style: ButtonStyle(
overlayColor: overlayColor:
MaterialStateColor.resolveWith((states) => Colors.white), WidgetStateColor.resolveWith((states) => Colors.white),
), ),
onPressed: () async { onPressed: () async {
if (locator.userManager.hasUser) { if (locator.userManager.hasUser) {
await locator.purchaseService.buyPackWithYooMoney(dto, context); Analytics.buyPackPressed(dto, PaymentSystem.yookassa);
if (await locator.purchaseService
.buyPackWithYooMoney(dto, context)) {
_updatePack();
}
} else { } else {
AppRouter.openAuth(); AppRouter.openAuth();
} }

View file

@ -1,37 +1,22 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:auto_route/annotations.dart'; import 'package:auto_route/annotations.dart';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.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:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:yandex_mobileads/mobile_ads.dart'; import '../../features/analytics/analytics.dart';
import '../../main.dart';
import '../items/builder.dart';
import '../../di/locator.dart'; import '../../di/locator.dart';
import '../../theme/themes.dart';
import '../header.dart';
import '../mnemo_text.dart';
import 'available_pack.dart'; import 'available_pack.dart';
import 'buy_pack_page.dart'; import 'buy_pack_page.dart';
import 'card_pack_header.dart'; import 'card_pack_header.dart';
import 'loading_pack.dart';
@RoutePage() @RoutePage()
class CardPackPage extends StatefulWidget { class CardPackPage extends StatefulWidget {
final String cardPackId; final CardPackPreviewDto previewDto;
const CardPackPage(this.cardPackId, {super.key}); const CardPackPage(this.previewDto, {super.key});
@override @override
State<StatefulWidget> createState() => _CardPackPage(); State<StatefulWidget> createState() => _CardPackPage();
@ -43,14 +28,27 @@ class _CardPackPage extends State<CardPackPage> {
return Scaffold( return Scaffold(
body: SafeArea( body: SafeArea(
child: StreamBuilder<GetCardPackResponse>( child: StreamBuilder<GetCardPackResponse>(
stream: locator.packManager.getCardPackStream(widget.cardPackId), stream: locator.packManager.getCardPackStream(
widget.previewDto.id,
widget.previewDto.isAvailable,
),
initialData: null, initialData: null,
builder: (context, snapshot) { 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; final response = snapshot.requireData;
if (response is CardPackBuyDto) if (response is CardPackBuyDto)
return BuyPack(response); 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( return Column(
children: [ children: [
CardPackHeader.fromDto(null), CardPackHeader.fromDto(null),
@ -71,19 +69,3 @@ class _CardPackPage extends State<CardPackPage> {
super.initState(); super.initState();
} }
} }
class _Loading extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
CardPackHeader.fromDto(null),
Expanded(
child: Center(
child: Text('Loading...'),
),
),
],
);
}
}

View file

@ -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<StatefulWidget> createState() => _CardsViewState();
}
class _CardsViewState extends State<CardsView>
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<GameCardDto> 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,
),
);
}
}

View file

@ -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<GameCardDto> cards = [];
final CardPackDto cardPack;
final ScrollController _cardsScrollController =
ScrollController(keepScrollOffset: true);
ScrollController get cardsScrollController => _cardsScrollController;
StreamController<CardsViewState> _streamController =
StreamController.broadcast();
Stream<CardsViewState> get asStream =>
_streamController.stream.startWith(state).distinct();
Stream<bool> get expandedStream => asStream.map((s) => s.expanded).distinct();
Stream<bool> get favoriteStream => asStream.map((s) => s.favorite).distinct();
Stream<List<GameCardDto>> 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<GameCardDto> _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<void> 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<GameCardDto> 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);
}
}

View file

@ -0,0 +1,10 @@
import 'package:flutter/cupertino.dart';
class HeartButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
// TODO: implement build
throw UnimplementedError();
}
}

View file

@ -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('Загрузка...'),
],
),
),
),
],
);
}
}

View file

@ -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,
);
},
);
}
}

View file

@ -47,13 +47,23 @@ class GameCardWidget extends StatefulWidget {
class GameCardDecoration extends StatelessWidget { class GameCardDecoration extends StatelessWidget {
final Widget child; final Widget child;
final double alpha; 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 @override
Widget build(BuildContext context) => Container( Widget build(BuildContext context) => Container(
padding: EdgeInsets.all(8.0 * alpha), 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( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
boxShadow: [ boxShadow: [
@ -66,7 +76,7 @@ class GameCardDecoration extends StatelessWidget {
borderRadius: BorderRadius.circular( borderRadius: BorderRadius.circular(
8 + (GameCardWidget.borderRadius - 8) * alpha), 8 + (GameCardWidget.borderRadius - 8) * alpha),
border: Border.all( border: Border.all(
color: borderGray.withOpacity(1.0 - alpha * alpha), color: (borderColor ?? borderGray).withOpacity(1.0 - alpha * alpha),
), ),
), ),
child: ClipRRect( child: ClipRRect(
@ -160,7 +170,7 @@ class _BackWidget extends StatelessWidget {
), ),
child: Center( child: Center(
child: MnemoText( child: MnemoText(
card.back ?? card.translation ?? '', card.back?.isNotEmpty == true ? card.back : card.translation ?? '',
textStyle: theme.displayMedium!.copyWith(color: Colors.white), textStyle: theme.displayMedium!.copyWith(color: Colors.white),
maxLines: 2, maxLines: 2,
softWrap: true, softWrap: true,
@ -199,8 +209,8 @@ class FrontCard extends StatelessWidget {
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
Padding( Padding(
padding: padding: EdgeInsets.all(
EdgeInsets.all(8.0 * imageScale * imageScale * alpha), 8.0 * imageScale * (alpha / 2 + 0.5)),
child: Column( child: Column(
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
mainAxisAlignment: ar > 1 mainAxisAlignment: ar > 1
@ -213,7 +223,7 @@ class FrontCard extends StatelessWidget {
child: MnemoText( child: MnemoText(
card.original ?? '', card.original ?? '',
textStyle: TextStyle( textStyle: TextStyle(
fontSize: 27 * textScale, fontSize: 15 * textScale + 12,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
maxLines: 1, maxLines: 1,
@ -229,11 +239,9 @@ class FrontCard extends StatelessWidget {
} }
}, },
), ),
if (alpha > 0)
GestureDetector( GestureDetector(
onTap: () async { onTap: () async {
final sp = final sp = await SharedPreferences.getInstance();
await SharedPreferences.getInstance();
if (sp.getBool('sound_on') != false && if (sp.getBool('sound_on') != false &&
card.translation != null) { card.translation != null) {
FlutterTts flutterTts = FlutterTts(); FlutterTts flutterTts = FlutterTts();
@ -247,13 +255,12 @@ class FrontCard extends StatelessWidget {
child: MnemoText( child: MnemoText(
card.translation?.toLowerCase(), card.translation?.toLowerCase(),
textStyle: TextStyle( textStyle: TextStyle(
fontSize: 20 * textScale * alpha, fontSize: 12 * textScale * alpha + 8,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
color: theme.headlineSmall!.color! color: theme.headlineSmall!.color!
.withOpacity(0.5 * alpha), .withOpacity(0.5),
), ),
maxLines: 1, maxLines: 1,
// group: smallSize,
), ),
), ),
if (alpha > 0 && if (alpha > 0 &&
@ -289,12 +296,17 @@ class FrontCard extends StatelessWidget {
), ),
], ],
), ),
SizedBox( Container(
width: constrains.maxHeight / ar * imageScale, width: constrains.maxHeight / ar * imageScale,
height: constrains.maxHeight / ar * imageScale, height: constrains.maxHeight / ar * imageScale,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
GameCardWidget.borderRadius)),
child: ImageFade( child: ImageFade(
key: ValueKey(card.id), key: ValueKey(card.id),
image: image, image: image,
// fix for elapsedInSeconds < 0 in debug mode
syncDuration: Duration.zero, syncDuration: Duration.zero,
), ),
), ),

View file

@ -34,7 +34,8 @@ typedef ImageFadeLoadingBuilder = Widget Function(
class ImageFade extends StatefulWidget { class ImageFade extends StatefulWidget {
/// Creates a widget that displays a [placeholder] widget while a specified [image] loads, /// Creates a widget that displays a [placeholder] widget while a specified [image] loads,
/// then cross-fades to the loaded image. /// then cross-fades to the loaded image.
const ImageFade({ const
ImageFade({
Key? key, Key? key,
this.placeholder, this.placeholder,
this.image, this.image,

View file

@ -33,6 +33,7 @@ class TextItemBuilder extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
padding: EdgeInsets.all(8.0),
decoration: BoxDecoration( decoration: BoxDecoration(
border: item.hasBorder border: item.hasBorder
? Border.all( ? Border.all(
@ -50,6 +51,7 @@ class TextItemBuilder extends StatelessWidget {
fontSize: 24, fontSize: 24,
color: Colors.black, color: Colors.black,
), ),
textAlign: TextAlign.center,
), ),
if (item.subtitle != null) if (item.subtitle != null)
Text( Text(

View file

@ -97,7 +97,13 @@ class _PacksMenuWidgetState extends State<PacksMenuWidget> {
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: locator.previewPackPoller.poll, onRefresh: locator.previewPackPoller.poll,
backgroundColor: Colors.white,
color: snapshot.data?.firstOrNull?.color?.asColor ??
Colors.black,
child: ListView( child: ListView(
physics: BouncingScrollPhysics(
decelerationRate: ScrollDecelerationRate.fast,
),
scrollDirection: Axis.vertical, scrollDirection: Axis.vertical,
children: [ children: [
if (snapshot.data == null) if (snapshot.data == null)
@ -127,7 +133,9 @@ class _PacksMenuWidgetState extends State<PacksMenuWidget> {
if (pack.imageBase64 != null) { if (pack.imageBase64 != null) {
try { try {
final bytes = base64Decode(pack.imageBase64!); 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) { } catch (e) {
log('${pack.id} cover not cached'); log('${pack.id} cover not cached');
} }
@ -154,7 +162,9 @@ class _PackButton4 extends StatelessWidget {
AutoRouter.of(context).push( AutoRouter.of(context).push(
PageRouteInfo( PageRouteInfo(
CardPackPage.name, CardPackPage.name,
args: CardPackPageArgs(cardPackId: pack.id), args: CardPackPageArgs(
previewDto: pack,
),
), ),
); );
}, },
@ -183,7 +193,8 @@ class _PackButton4 extends StatelessWidget {
image: pack.imageBase64 == null image: pack.imageBase64 == null
? null ? null
: DecorationImage( : DecorationImage(
image: MemoryImage( image: locator.imagesHolder.get('pack_${pack.id}') ??
MemoryImage(
base64Decode(pack.imageBase64!), base64Decode(pack.imageBase64!),
), ),
), ),
@ -276,7 +287,7 @@ class _PackButton4Shimmer extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Shimmer.fromColors( return Shimmer.fromColors(
baseColor: Colors.white, baseColor: Colors.white,
highlightColor: Colors.white, highlightColor: borderGray,
child: Container( child: Container(
alignment: Alignment.center, alignment: Alignment.center,
height: cardHeight, height: cardHeight,

View file

@ -13,10 +13,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: _flutterfire_internals name: _flutterfire_internals
sha256: "2350805d7afefb0efe7acd325cb19d3ae8ba4039b906eade3807ffb69938a01f" sha256: "37a42d06068e2fe3deddb2da079a8c4d105f241225ba27b7122b37e9865fd8f7"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.33" version: "1.3.35"
analyzer: analyzer:
dependency: transitive dependency: transitive
description: description:
@ -353,14 +353,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.0.3" 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: firebase_core:
dependency: "direct main" dependency: "direct main"
description: description:
name: firebase_core name: firebase_core
sha256: "372d94ced114b9c40cb85e18c50ac94a7e998c8eec630c50d7aec047847d27bf" sha256: "26de145bb9688a90962faec6f838247377b0b0d32cc0abecd9a4e43525fc856c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.31.0" version: "2.32.0"
firebase_core_platform_interface: firebase_core_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -377,6 +401,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.17.0" 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: fixnum:
dependency: transitive dependency: transitive
description: description:

View file

@ -57,6 +57,8 @@ dependencies:
device_info_plus: ^9.1.2 device_info_plus: ^9.1.2
package_info_plus: ^8.0.0 package_info_plus: ^8.0.0
yandex_mobileads: ^6.1.0 yandex_mobileads: ^6.1.0
firebase_crashlytics: ^3.5.7
firebase_analytics: ^10.10.7
in_app_purchase: ^3.2.0 in_app_purchase: ^3.2.0
dot_navigation_bar: ^1.0.2 dot_navigation_bar: ^1.0.2
story: ^1.1.0 story: ^1.1.0