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

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

View file

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

View file

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

View file

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

View file

View file

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

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 {
Map<String, MemoryImage> _images = {};
Map<String, MemoryImage> _packImages = {};
DateTime? unlockTime;
ImagesHolder();
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) {
final memoryImage = _images[id];
final memoryImage = getPackImage(id) ?? _images[id];
return memoryImage;
}
MemoryImage? getPackImage(String id) {
final memoryImage = _packImages[id];
return memoryImage;
}
}
extension ImagesHolderStringExt on String {
@ -30,7 +43,3 @@ extension ImagesHolderStringExt on String {
extension ImagesHolderCardExt on GameCardDto {
MemoryImage? get memoryImage => locator.imagesHolder.get(id.toString());
}
extension ImagesHolderIntExt on int {
MemoryImage? get memoryImage => locator.imagesHolder.get(toString());
}

View file

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

View file

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

View file

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

View file

@ -1,7 +1,12 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:rxdart/rxdart.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../main.dart';
class PreviewPackHolder {
final StreamController<PreviewPackState?> _streamController =
@ -10,8 +15,52 @@ class 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);
if (save) {
savePreviewPacks();
}
_streamController.add(_state);
}

View file

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

View file

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

View file

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

View file

@ -11,7 +11,7 @@ abstract class _$InputButtonsTestStateCWProxy {
InputButtonsTestState answer(List<String> answer);
InputButtonsTestState answerIndexes(List<int> answerIndexes);
InputButtonsTestState answerIds(List<String> answerIds);
InputButtonsTestState isCorrect(bool isCorrect);
@ -28,7 +28,7 @@ abstract class _$InputButtonsTestStateCWProxy {
InputButtonsTestState call({
int? seed,
List<String>? answer,
List<int>? answerIndexes,
List<String>? answerIds,
bool? isCorrect,
bool? isAnswered,
TestQuestionType? testType,
@ -49,8 +49,8 @@ class _$InputButtonsTestStateCWProxyImpl
InputButtonsTestState answer(List<String> answer) => this(answer: answer);
@override
InputButtonsTestState answerIndexes(List<int> answerIndexes) =>
this(answerIndexes: answerIndexes);
InputButtonsTestState answerIds(List<String> answerIds) =>
this(answerIds: answerIds);
@override
InputButtonsTestState isCorrect(bool isCorrect) => this(isCorrect: isCorrect);
@ -74,7 +74,7 @@ class _$InputButtonsTestStateCWProxyImpl
InputButtonsTestState call({
Object? seed = const $CopyWithPlaceholder(),
Object? answer = const $CopyWithPlaceholder(),
Object? answerIndexes = const $CopyWithPlaceholder(),
Object? answerIds = const $CopyWithPlaceholder(),
Object? isCorrect = const $CopyWithPlaceholder(),
Object? isAnswered = const $CopyWithPlaceholder(),
Object? testType = const $CopyWithPlaceholder(),
@ -88,11 +88,10 @@ class _$InputButtonsTestStateCWProxyImpl
? _value.answer
// ignore: cast_nullable_to_non_nullable
: answer as List<String>,
answerIndexes:
answerIndexes == const $CopyWithPlaceholder() || answerIndexes == null
? _value.answerIndexes
// ignore: cast_nullable_to_non_nullable
: answerIndexes as List<int>,
answerIds: answerIds == const $CopyWithPlaceholder() || answerIds == null
? _value.answerIds
// ignore: cast_nullable_to_non_nullable
: answerIds as List<String>,
isCorrect: isCorrect == const $CopyWithPlaceholder() || isCorrect == null
? _value.isCorrect
// ignore: cast_nullable_to_non_nullable
@ -129,8 +128,8 @@ InputButtonsTestState _$InputButtonsTestStateFromJson(
?.map((e) => e as String)
.toList() ??
const [],
answerIndexes: (json['answerIndexes'] as List<dynamic>?)
?.map((e) => (e as num).toInt())
answerIds: (json['answerIds'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
const [],
isCorrect: json['isCorrect'] as bool? ?? false,
@ -145,7 +144,7 @@ Map<String, dynamic> _$InputButtonsTestStateToJson(
<String, dynamic>{
'testType': _$TestQuestionTypeEnumMap[instance.testType]!,
'answer': instance.answer,
'answerIndexes': instance.answerIndexes,
'answerIds': instance.answerIds,
'isCorrect': instance.isCorrect,
'isAnswered': instance.isAnswered,
'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_widgets/test_image.dart';
import 'package:mnemo_cards/features/packs/pack_cache_manager.dart';
import 'package:mnemo_cards/main.dart';
import 'package:mnemo_cards/theme/themes.dart';
import 'package:mnemo_cards/utils/iterable_helper.dart';
import 'package:mnemo_cards/utils/string_helper.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:rxdart/rxdart.dart';
import '../../managers/repository/http_repository.dart';
import 'question_states/test_question_state.dart';
@ -28,6 +30,18 @@ class TestManager extends ChangeNotifier {
return tests;
}
Stream<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);
Iterable<TestQuestionState> get states => testHolder.state.questions.values;
@ -37,7 +51,15 @@ class TestManager extends ChangeNotifier {
if (packId != null) {
await packCacheManager.loadPackFromCache(packId);
}
final test = await _repository.getTest(id);
TestDto? test;
try {
test = await _repository.getTest(id);
} on Object catch (e, s) {
log('error when loading test ${id}');
}
if (test == null) {
appRouter.maybePop();
}
questions.clear();
questions.addAll(test!.questions);
color = test.color?.asColor ?? borderGray;

View file

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

View file

@ -1,129 +1,101 @@
import 'dart:math';
import 'package:collection/collection.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:mnemo_cards/features/tests/test_manager.dart';
import 'package:mnemo_cards/features/tests/test_widgets/test_image.dart';
import 'package:mnemo_cards/theme/themes.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:collection/collection.dart';
import '../../../di/locator.dart';
import '../../../widgets/game_card_widget.dart';
import '../question_states/input_buttons_test_state.dart';
import 'test_button.dart';
class InputButtonsTestWidget extends StatelessWidget {
final InputButtonsTestQuestionBody model;
bool get isCorrect => state.answer.join() == model.answer;
final InputButtonsTestQuestionBody _inputButtonModel;
InputButtonsTestQuestionBody get model => _inputButtonModel;
InputButtonsTestState get state =>
manager.getState(model.id!) as InputButtonsTestState;
TestManager get manager => locator.testManager;
InputButtonsTestWidget(this.model, {super.key});
Color get testColor => manager.color;
Widget _letter(BuildContext context, String text) => Padding(
padding: const EdgeInsets.all(4.0),
child: Material(
child: Container(
padding: const EdgeInsets.all(8.0),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8.0),
boxShadow: const [
BoxShadow(
color: Colors.black26,
blurRadius: 4.0,
),
],
),
child: Text(
text,
style: Theme.of(context).textTheme.titleLarge,
),
),
),
);
Widget _emptyCell(BuildContext context) => Padding(
padding: const EdgeInsets.all(4.0),
child: Material(
child: Container(
padding: const EdgeInsets.all(8.0),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8.0),
boxShadow: const [
BoxShadow(
color: Colors.black26,
blurRadius: 4.0,
),
],
),
child: Text(
' ',
style: Theme.of(context).textTheme.titleLarge,
),
),
),
);
InputButtonsTestWidget(this._inputButtonModel, {super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context).textTheme;
final random = Random(state.seed);
final buttons = model.buttons..shuffle(random);
return LayoutBuilder(builder: (context, constraints) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
// final random = Random(state.seed);
final buttons = model.buttons; //..shuffle(random);
return Padding(
padding: const EdgeInsets.all(8.0),
child: LayoutBuilder(builder: (context, constraints) {
return Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
flex: 2,
child: AspectRatio(
aspectRatio: 1,
child: Container(
margin: const EdgeInsets.all(4.0),
decoration: BoxDecoration(
color: Colors.white,
borderRadius:
BorderRadius.circular(GameCardWidget.borderRadius),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (model.image != null)
TestImageWidget(
TestImage.image(model.image!),
if (model.image != null)
Container(
margin: const EdgeInsets.all(4.0),
decoration: BoxDecoration(
color: Colors.white,
borderRadius:
BorderRadius.circular(16.0),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AspectRatio(
aspectRatio: 1,
child: TestImageWidget(
TestImage.image(model.image!),
),
),
if (model.text != null)
Container(
padding: EdgeInsets.only(top: 8.0),
child: Center(
child: Text(
model.text!,
style: theme.headlineLarge,
),
if (model.text != null)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Center(
child: Text(
model.text!,
style: theme.headlineLarge,
),
),
),
],
),
),
),
],
),
)
else Flexible(
child: Center(
child: Text(
model.text!,
style: theme.headlineLarge,
),
),
),
StatefulBuilder(builder: (context, setState) {
void buttonTapped((int, TestButtonDto) p) {
void buttonTapped(int index, TestButtonDto button) {
if (state.answer.fold(0, (p, n) => p + n.length) >=
model.answer.length) {
return;
}
setState(() {
var answer = [...state.answer, button.text!];
final ignoreSpaces = true;
if (ignoreSpaces &&
model.answer.length > answer.length &&
model.answer[answer.length] == ' ') {
answer.add(' ');
}
manager.setState(
model.id!,
state.copyWith(
answer: [...state.answer, p.$2.text!],
answer: answer,
answerIds: [...state.answerIds, button.id],
),
);
manager.setState(
@ -143,35 +115,91 @@ class InputButtonsTestWidget extends StatelessWidget {
}
}
int splitIndex = 0;
if (model.answer.length * 30 > constraints.maxWidth * 0.9) {
splitIndex = model.answer.indexOf(
' ',
(model.answer.length / 2).ceil(),
);
if (splitIndex < 10) {
splitIndex = model.answer.indexOf(
' ',
(model.answer.length / 3).ceil(),
);
}
if (splitIndex == -1) {
splitIndex = model.answer.indexOf(
' ',
0,
);
}
}
if (splitIndex < 0) {
splitIndex = 0;
}
final letters = model.answer
.split('')
.mapIndexed(
(index, letter) => letter == ' '
? SizedBox(width: 10)
: _Letter(
text: state.answer.length > index
? state.answer[index]
: ' ',
color: testColor,
),
)
.toList();
final rows = [
if (splitIndex > 0)
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: letters.sublist(0, splitIndex),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: letters.sublist(splitIndex),
)
];
return Expanded(
child: Column(
children: [
Expanded(
child: GestureDetector(
onTap: () {
var answer = state.answer.toList();
var answerIds = state.answerIds.toList();
if (answer.isNotEmpty) {
if (answer.last == ' ') {
answer.removeLast();
}
if (answer.isNotEmpty) {
answer.removeLast();
answerIds.removeLast();
}
}
setState(() {
manager.setState(
model.id!,
state.copyWith(
isAnswered: false,
isCorrect: false,
answer: [],
answerIndexes: [],
// isAnswered: false,
// isCorrect: false,
answer: answer,
answerIds: answerIds,
),
);
});
},
child: Row(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
...state.answer.map((e) => _letter(context, e)),
...model.answer
.substring(
state.answer.fold(0, (p, n) => p + n.length),
)
.characters
.map((e) => _emptyCell(context)),
],
mainAxisSize: MainAxisSize.min,
children: rows,
),
),
),
@ -181,22 +209,25 @@ class InputButtonsTestWidget extends StatelessWidget {
AbsorbPointer(
absorbing: state.isCorrect,
child: Wrap(
spacing: 8.0,
runSpacing: 8.0,
children: [
...buttons.indexed.map(
(p) => Container(
width: p.$2.text!.length > 3 ? null : 80,
height: 60,
child: TestButton(
text: p.$2.text,
onTap: () => buttonTapped(p),
color: state.answer == p.$2
? isCorrect
? Colors.green[200]
: Colors.red[200]
: Colors.white,
),
...buttons.mapIndexed(
(index, button) => Container(
margin: EdgeInsets.all(4.0),
width: button.text!.length > 3
? null
: constraints.maxWidth / 6,
height: 50,
child: state.answerIds.contains(button.id)
? SizedBox.shrink()
: TestButton(
text: button.text,
onTap: () => buttonTapped(index, button),
color: state.answer == button
? isCorrect
? Colors.green[200]
: Colors.red[200]
: Colors.white,
),
),
),
],
@ -207,8 +238,45 @@ class InputButtonsTestWidget extends StatelessWidget {
);
})
],
),
);
});
);
}),
);
}
}
class _Letter extends StatelessWidget {
final String text;
final Color? color;
_Letter({this.text = '', this.color});
@override
Widget build(BuildContext context) {
return Container(
width: 30,
height: 45,
margin: const EdgeInsets.all(2.0),
padding: const EdgeInsets.all(5.0),
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(6.0),
border: Border.all(color: color ?? borderGray),
boxShadow: const [
// BoxShadow(
// color: Colors.black26,
// blurRadius: 4.0,
// ),
],
),
child: Material(
color: Colors.white,
child: Text(
text,
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
),
);
}
}

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,16 +1,20 @@
import 'dart:async';
import 'dart:math';
import 'package:collection/collection.dart';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/animation.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:mnemo_cards/features/packs/images_holder.dart';
import 'package:mnemo_cards/main.dart';
import 'package:mnemo_cards/utils/iterable_helper.dart';
import 'package:mnemo_cards/widgets/fade_in_widget.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:yandex_mobileads/mobile_ads.dart';
import '../../domain/router/app_router.gr.dart';
import '../../features/analytics/analytics.dart';
import '../../features/yandex_ads/yandex_ads.dart';
import '../game_card_widget.dart';
@ -19,67 +23,113 @@ import '../../theme/themes.dart';
import 'card_pack_header.dart';
import 'package:mnemo_cards/utils/string_helper.dart';
import 'cards_view.dart';
import 'cards_view_controller.dart';
final _mainScrollController = ScrollController();
class AvailablePack extends StatelessWidget {
final CardPackDto cardPack;
final _CardsViewController _cardsViewController = _CardsViewController();
late final CardsViewController _cardsViewController =
CardsViewController(cardPack);
AvailablePack(this.cardPack);
@override
Widget build(BuildContext context) {
Analytics.availablePackOpened(cardPack);
return Column(
children: [
CardPackHeader.fromDto(cardPack),
Expanded(
child: ListView(
controller: _mainScrollController,
children: [
StatefulBuilder(builder: (context, setState) {
return Column(
children: [
Container(
padding: EdgeInsets.symmetric(vertical: 0.h),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: cardPack.color?.asColor ??
Colors.grey.withOpacity(0.5)),
),
color: cardPack.color?.asColor?.withOpacity(0.1),
),
alignment: Alignment.center,
clipBehavior: Clip.antiAlias,
child: _CardsView(_cardsViewController, cardPack),
child: CardsView(_cardsViewController, cardPack),
),
Container(
height: 1,
color: cardPack.color?.asColor ??
Colors.grey.withOpacity(0.5),
),
SizedBox(
height: 10.h,
),
Padding(
padding: EdgeInsets.symmetric(
vertical: 8.0.h,
horizontal: 10.w,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_Button('view.png', () async {
_cardsViewController.toggleExpanded();
}),
StreamBuilder(
stream: _cardsViewController.asStream,
builder: (context, snapshot) {
return AnimatedRotation(
duration: Duration(milliseconds: 300),
turns: (snapshot.data?.expanded ?? false)
? 0.5
: 0.0,
child: _Button(
'down.png',
() async {
if (snapshot.hasData) {
_cardsViewController.setExpanded(
!snapshot.requireData.expanded,
);
}
},
),
);
}),
_Button('shuffle.png', () async {
final index =
Random().nextInt(cardPack.cards.length);
final cards = _cardsViewController.state.packCards;
if (cards.isEmpty) return;
final index = (_cardsViewController.offsetCards +
Random().nextInt(
(cards.length / 2).ceil(),
) +
3) %
cards.length;
await _cardsViewController.animateTo(index);
AutoRouter.of(context).push(
PageRouteInfo(
CardGamePage.name,
args: CardGamePageArgs(
cardPack: cardPack,
startCardId: cardPack.cards[index].id,
cards: cards,
color: cardPack.color?.asColor,
startCardId: cards[index].id,
infinite: true,
shuffle: true,
// id: cardPack.dto.id.toString(),
),
),
);
}),
// _Button('chat.png', () {}),
_Button('heart.png', () {}),
StreamBuilder(
stream: _cardsViewController.asStream,
builder: (context, snapshot) {
return _Button(
(snapshot.data?.favorite ?? false)
? 'heart_fill.png'
: 'heart.png', () {
if (snapshot.hasData) {
_cardsViewController.setFavorite(
!snapshot.requireData.favorite,
);
}
});
}),
],
),
),
@ -124,24 +174,28 @@ class _TestsSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: locator.testManager.loadTests(packId: packId),
return StreamBuilder(
stream: locator.testManager.loadTestsStream(packId: packId),
builder: (context, snapshot) {
return Column(
children: [
if (snapshot.hasData)
...snapshot.data!.map(
(dto) => _TestButton(
title: dto.name,
subtitle: null,
time: dto.time,
timeSubtitle: dto.timeSubtitle,
onTap: () {
AutoRouter.of(context).push(
PageRouteInfo(TestPage.name,
args: TestPageArgs(testId: dto.id!)),
);
},
...snapshot.data!.mapIndexed(
(int index, TestDto dto) => FadeInWidget(
delay: Duration(milliseconds: index * 200),
animationDuration: const Duration(milliseconds: 200),
child: _TestButton(
title: dto.name,
subtitle: null,
time: dto.time,
timeSubtitle: dto.timeSubtitle,
onTap: () {
AutoRouter.of(context).push(
PageRouteInfo(TestPage.name,
args: TestPageArgs(testId: dto.id!)),
);
},
),
),
),
],
@ -152,28 +206,33 @@ class _TestsSection extends StatelessWidget {
}
class _AdTile extends StatelessWidget {
const _AdTile();
const _AdTile({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context).textTheme;
return Container(
height: 80,
margin: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0),
decoration: BoxDecoration(
border: Border.all(
color: Colors.white,
width: 4.0,
return LayoutBuilder(builder: (context, constraints) {
final banner = YandexAds.createBanner(
BoxConstraints.tight(Size(constraints.maxWidth - 16, 80 - 8)));
return FadeInWidget(
future: banner.loadAd(),
animationDuration: Duration(milliseconds: 1000),
child: Container(
height: 80,
margin: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0),
decoration: BoxDecoration(
border: Border.all(
color: Colors.white,
width: 4.0,
),
borderRadius: BorderRadius.circular(12.0),
),
child: Center(
child: AdWidget(bannerAd: banner),
),
),
borderRadius: BorderRadius.circular(12.0),
),
child: Center(
child: LayoutBuilder(builder: (context, constraints) {
return AdWidget(
bannerAd: YandexAds.createBanner(constraints)..loadAd());
}),
),
);
);
});
}
}
@ -181,10 +240,7 @@ class _Button extends StatelessWidget {
final String asset;
final VoidCallback onTap;
_Button(
this.asset,
this.onTap,
);
_Button(this.asset, this.onTap, {super.key});
@override
Widget build(BuildContext context) {
@ -305,206 +361,3 @@ class _TestButton extends StatelessWidget {
);
}
}
class _CardBuilder extends StatelessWidget {
final GameCardDto card;
final CardPackDto cardPack;
final MemoryImage image;
final Function(GameCardDto) onTap;
_CardBuilder(
this.cardPack,
this.card,
this.image,
this.onTap,
);
@override
Widget build(BuildContext context) {
return GestureDetector(
key: ObjectKey(card),
onTap: () => onTap(card),
child: Hero(
tag: card.id,
child: GameCardDecoration(
alpha: 0.0,
child: FrontCard(
card,
image,
alpha: 0,
),
),
createRectTween: (a, b) => RectTween(begin: a, end: b),
flightShuttleBuilder: (flightC, a, dir, fromC, toC) => AnimatedBuilder(
animation: a,
builder: (context, child) => GameCardDecoration(
alpha: a.value,
child: FrontCard(
card,
card.memoryImage!,
alpha: a.value,
),
),
),
),
);
}
}
class _CardsView extends StatefulWidget {
final CardPackDto cardPack;
final _CardsViewController controller;
_CardsView(this.controller, this.cardPack);
@override
State<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:developer';
import 'package:auto_route/auto_route.dart';
import 'package:mnemo_cards/utils/string_helper.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import '../../di/injector.dart';
import '../../features/analytics/analytics.dart';
import '../../features/packs/pack_holder.dart';
import '../../main.dart';
import '../items/builder.dart';
import '../../di/locator.dart';
import '../../domain/router/app_router.dart';
import '../../theme/themes.dart';
import 'card_pack_header.dart';
import 'loading_pack.dart';
class BuyPack extends StatelessWidget {
final CardPackBuyDto dto;
@ -18,74 +25,135 @@ class BuyPack extends StatelessWidget {
@override
Widget build(BuildContext context) {
bool loading = false;
Analytics.buyPackOpened(dto);
return Container(
// color: dto.color?.asColor,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
CardPackHeader(dto.title, dto.subtitle, dto.color?.asColor),
Expanded(
child: Column(
children: [
Padding(
padding:
EdgeInsets.symmetric(horizontal: 10.0.w, vertical: 18.h),
child: LayoutBuilder(builder: (context, constraints) {
final cardWidth = constraints.maxWidth / 3;
final imageWidth = cardWidth * 0.9;
return SizedBox(
height: 150.h,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: dto.cards.length,
itemExtent: cardWidth,
itemBuilder: (BuildContext context, int index) {
final card = dto.cards[index];
if (card.image != null) {
return Container(
alignment: Alignment.center,
width: imageWidth,
margin: EdgeInsets.symmetric(
horizontal: cardWidth - imageWidth - 1,
vertical: 1,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8.0),
border: Border.all(
color: borderGray,
child: StatefulBuilder(builder: (context, setState) {
if (loading) {
return _Loading(dto);
}
return Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
CardPackHeader(dto.title, dto.subtitle, dto.color?.asColor),
Expanded(
child: Column(
children: [
Padding(
padding: EdgeInsets.symmetric(
horizontal: 10.0.w, vertical: 18.h),
child: LayoutBuilder(builder: (context, constraints) {
final cardWidth = constraints.maxWidth / 3;
final imageWidth = cardWidth * 0.9;
return SizedBox(
height: 150.h,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: dto.cards.length,
itemExtent: cardWidth,
itemBuilder: (BuildContext context, int index) {
final card = dto.cards[index];
if (card.image != null) {
return Container(
alignment: Alignment.center,
width: imageWidth,
margin: EdgeInsets.symmetric(
horizontal: cardWidth - imageWidth - 1,
vertical: 1,
),
),
child: Image.memory(base64Decode(card.image!)),
);
}
return SizedBox.shrink();
},
),
);
}),
),
SizedBox(
height: 32.h,
),
...?dto.items?.build(),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8.0),
border: Border.all(
color: borderGray,
),
),
child: Image.memory(base64Decode(card.image!)),
);
}
return SizedBox.shrink();
},
),
);
}),
),
SizedBox(
height: 32.h,
),
...?dto.items?.build(),
],
),
),
_BuyButton(dto, (v) {
setState(() {
loading = v;
});
}),
SizedBox(
height: 16.h,
),
],
);
}),
);
}
}
class _Loading extends StatelessWidget {
final CardPackBuyDto dto;
_Loading(this.dto);
@override
Widget build(BuildContext context) {
final memoryImage = locator.imagesHolder.get('pack_${dto.id}');
return Column(
children: [
CardPackHeader(dto.title, dto.subtitle, dto.color?.asColor),
Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (memoryImage != null)
Padding(
padding: const EdgeInsets.all(8.0),
child: Image(
image: memoryImage,
width: 100,
),
),
Text('Загрузка...'),
],
),
),
_BuyButton(dto),
SizedBox(
height: 16.h,
),
],
),
),
],
);
}
}
class _BuyButton extends StatelessWidget {
final CardPackBuyDto dto;
final Function(bool) callback;
_BuyButton(this.dto);
_BuyButton(this.dto, this.callback);
Future<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
Widget build(BuildContext context) {
@ -101,7 +169,11 @@ class _BuyButton extends StatelessWidget {
InkWell(
onTap: () async {
if (locator.userManager.hasUser) {
final result = locator.purchaseService.buyPack(dto);
Analytics.buyPackPressed(dto, PaymentSystem.google);
final result = await locator.purchaseService.buyPack(dto);
if (result) {
_updatePack();
}
} else {
AppRouter.openAuth();
}
@ -145,11 +217,15 @@ class _BuyButton extends StatelessWidget {
TextButton(
style: ButtonStyle(
overlayColor:
MaterialStateColor.resolveWith((states) => Colors.white),
WidgetStateColor.resolveWith((states) => Colors.white),
),
onPressed: () async {
if (locator.userManager.hasUser) {
await locator.purchaseService.buyPackWithYooMoney(dto, context);
Analytics.buyPackPressed(dto, PaymentSystem.yookassa);
if (await locator.purchaseService
.buyPackWithYooMoney(dto, context)) {
_updatePack();
}
} else {
AppRouter.openAuth();
}

View file

@ -1,37 +1,22 @@
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:auto_route/annotations.dart';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:mnemo_cards/domain/router/app_router.dart';
import 'package:mnemo_cards/domain/router/app_router.gr.dart';
import 'package:mnemo_cards/features/packs/images_holder.dart';
import 'package:mnemo_cards/features/yandex_ads/yandex_ads.dart';
import 'package:mnemo_cards/utils/iterable_helper.dart';
import 'package:mnemo_cards/utils/string_helper.dart';
import 'package:mnemo_cards/widgets/game_card_widget.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:yandex_mobileads/mobile_ads.dart';
import '../../main.dart';
import '../items/builder.dart';
import '../../features/analytics/analytics.dart';
import '../../di/locator.dart';
import '../../theme/themes.dart';
import '../header.dart';
import '../mnemo_text.dart';
import 'available_pack.dart';
import 'buy_pack_page.dart';
import 'card_pack_header.dart';
import 'loading_pack.dart';
@RoutePage()
class CardPackPage extends StatefulWidget {
final String cardPackId;
final CardPackPreviewDto previewDto;
const CardPackPage(this.cardPackId, {super.key});
const CardPackPage(this.previewDto, {super.key});
@override
State<StatefulWidget> createState() => _CardPackPage();
@ -43,14 +28,27 @@ class _CardPackPage extends State<CardPackPage> {
return Scaffold(
body: SafeArea(
child: StreamBuilder<GetCardPackResponse>(
stream: locator.packManager.getCardPackStream(widget.cardPackId),
stream: locator.packManager.getCardPackStream(
widget.previewDto.id,
widget.previewDto.isAvailable,
),
initialData: null,
builder: (context, snapshot) {
if (!snapshot.hasData) return _Loading();
if (!snapshot.hasData) return SizedBox.shrink();
if (snapshot.requireData.responseType ==
GetCardPackResponseType.loading)
return Loading(widget.previewDto);
final response = snapshot.requireData;
if (response is CardPackBuyDto)
return BuyPack(response);
else if (response is CardPackDto) return AvailablePack(response);
else if (response is CardPackDto) {
return AvailablePack(response);
}
Analytics.error(
data: {'response': jsonEncode(snapshot.data?.toJson() ?? '{}')},
message: 'cant load pack ${widget.previewDto.id}',
);
return Column(
children: [
CardPackHeader.fromDto(null),
@ -71,19 +69,3 @@ class _CardPackPage extends State<CardPackPage> {
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 {
final Widget child;
final double alpha;
final Color? borderColor;
GameCardDecoration({required this.child, this.alpha = 1.0});
GameCardDecoration({
required this.child,
this.alpha = 1.0,
this.borderColor,
});
@override
Widget build(BuildContext context) => Container(
padding: EdgeInsets.all(8.0 * alpha),
margin: EdgeInsets.all(4.0 * alpha),
margin: EdgeInsets.only(
left: 10.0 * alpha,
right: 10.0 * alpha,
bottom: 10.0 * alpha,
top: 0.0 * alpha,
),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
@ -66,7 +76,7 @@ class GameCardDecoration extends StatelessWidget {
borderRadius: BorderRadius.circular(
8 + (GameCardWidget.borderRadius - 8) * alpha),
border: Border.all(
color: borderGray.withOpacity(1.0 - alpha * alpha),
color: (borderColor ?? borderGray).withOpacity(1.0 - alpha * alpha),
),
),
child: ClipRRect(
@ -160,7 +170,7 @@ class _BackWidget extends StatelessWidget {
),
child: Center(
child: MnemoText(
card.back ?? card.translation ?? '',
card.back?.isNotEmpty == true ? card.back : card.translation ?? '',
textStyle: theme.displayMedium!.copyWith(color: Colors.white),
maxLines: 2,
softWrap: true,
@ -199,8 +209,8 @@ class FrontCard extends StatelessWidget {
alignment: Alignment.center,
children: [
Padding(
padding:
EdgeInsets.all(8.0 * imageScale * imageScale * alpha),
padding: EdgeInsets.all(
8.0 * imageScale * (alpha / 2 + 0.5)),
child: Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: ar > 1
@ -213,7 +223,7 @@ class FrontCard extends StatelessWidget {
child: MnemoText(
card.original ?? '',
textStyle: TextStyle(
fontSize: 27 * textScale,
fontSize: 15 * textScale + 12,
fontWeight: FontWeight.w600,
),
maxLines: 1,
@ -229,33 +239,30 @@ class FrontCard extends StatelessWidget {
}
},
),
if (alpha > 0)
GestureDetector(
onTap: () async {
final sp =
await SharedPreferences.getInstance();
if (sp.getBool('sound_on') != false &&
card.translation != null) {
FlutterTts flutterTts = FlutterTts();
await flutterTts.setLanguage('ru');
await flutterTts.setSpeechRate(0.5);
flutterTts.speak(
card.translation!,
);
}
},
child: MnemoText(
card.translation?.toLowerCase(),
textStyle: TextStyle(
fontSize: 20 * textScale * alpha,
fontWeight: FontWeight.w400,
color: theme.headlineSmall!.color!
.withOpacity(0.5 * alpha),
),
maxLines: 1,
// group: smallSize,
GestureDetector(
onTap: () async {
final sp = await SharedPreferences.getInstance();
if (sp.getBool('sound_on') != false &&
card.translation != null) {
FlutterTts flutterTts = FlutterTts();
await flutterTts.setLanguage('ru');
await flutterTts.setSpeechRate(0.5);
flutterTts.speak(
card.translation!,
);
}
},
child: MnemoText(
card.translation?.toLowerCase(),
textStyle: TextStyle(
fontSize: 12 * textScale * alpha + 8,
fontWeight: FontWeight.w400,
color: theme.headlineSmall!.color!
.withOpacity(0.5),
),
maxLines: 1,
),
),
if (alpha > 0 &&
ar > 1 &&
card.transcription != null &&
@ -289,12 +296,17 @@ class FrontCard extends StatelessWidget {
),
],
),
SizedBox(
Container(
width: constrains.maxHeight / ar * imageScale,
height: constrains.maxHeight / ar * imageScale,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
GameCardWidget.borderRadius)),
child: ImageFade(
key: ValueKey(card.id),
image: image,
// fix for elapsedInSeconds < 0 in debug mode
syncDuration: Duration.zero,
),
),

View file

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

View file

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

View file

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

View file

@ -13,10 +13,10 @@ packages:
dependency: transitive
description:
name: _flutterfire_internals
sha256: "2350805d7afefb0efe7acd325cb19d3ae8ba4039b906eade3807ffb69938a01f"
sha256: "37a42d06068e2fe3deddb2da079a8c4d105f241225ba27b7122b37e9865fd8f7"
url: "https://pub.dev"
source: hosted
version: "1.3.33"
version: "1.3.35"
analyzer:
dependency: transitive
description:
@ -353,14 +353,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "8.0.3"
firebase_analytics:
dependency: "direct main"
description:
name: firebase_analytics
sha256: dbf1e7ab22cfb1f4a4adb103b46a26276b4edc593d4a78ef6fb942bafc92e035
url: "https://pub.dev"
source: hosted
version: "10.10.7"
firebase_analytics_platform_interface:
dependency: transitive
description:
name: firebase_analytics_platform_interface
sha256: "3729b74f8cf1d974a27ba70332ecb55ff5ff560edc8164a6469f4a055b429c37"
url: "https://pub.dev"
source: hosted
version: "3.10.8"
firebase_analytics_web:
dependency: transitive
description:
name: firebase_analytics_web
sha256: "019cd7eee74254d33fbd2e29229367ce33063516bf6b3258a341d89e3b0f1655"
url: "https://pub.dev"
source: hosted
version: "0.5.7+7"
firebase_core:
dependency: "direct main"
description:
name: firebase_core
sha256: "372d94ced114b9c40cb85e18c50ac94a7e998c8eec630c50d7aec047847d27bf"
sha256: "26de145bb9688a90962faec6f838247377b0b0d32cc0abecd9a4e43525fc856c"
url: "https://pub.dev"
source: hosted
version: "2.31.0"
version: "2.32.0"
firebase_core_platform_interface:
dependency: transitive
description:
@ -377,6 +401,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.17.0"
firebase_crashlytics:
dependency: "direct main"
description:
name: firebase_crashlytics
sha256: "9897c01efaa950d2f6da8317d12452749a74dc45f33b46390a14cfe28067f271"
url: "https://pub.dev"
source: hosted
version: "3.5.7"
firebase_crashlytics_platform_interface:
dependency: transitive
description:
name: firebase_crashlytics_platform_interface
sha256: "16a71e08fbf6e00382816e1b13397898c29a54fa0ad969c2c2a3b82a704877f0"
url: "https://pub.dev"
source: hosted
version: "3.6.35"
fixnum:
dependency: transitive
description:

View file

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