draggable

This commit is contained in:
Dmitry 2024-06-13 08:06:50 +03:00
parent 69930bd9df
commit a6a7ea6c8c
13 changed files with 420 additions and 162 deletions

View file

@ -198,7 +198,10 @@ class _CardSwiperState<T extends Widget> extends State<CardSwiper>
final _undoableIndex = Undoable<int?>(null);
final Queue<CardSwiperDirection> _directionHistory = Queue();
int? get _currentIndex => _undoableIndex.state;
bool usePrevIndex = false;
int? get _currentIndex =>
usePrevIndex ? _undoableIndex.previousState : _undoableIndex.state;
int? get _nextIndex => getValidIndexOffset(1);
@ -319,13 +322,13 @@ class _CardSwiperState<T extends Widget> extends State<CardSwiper>
onPanUpdate: (tapInfo) {
if (!widget.isDisabled &&
tapInfo.delta.dx.abs() > 2 * tapInfo.delta.dy.abs()) {
setState(
() => _cardAnimation.update(
setState(() {
_cardAnimation.update(
tapInfo.delta.dx,
tapInfo.delta.dy,
_tappedOnTop,
),
);
);
});
}
},
onPanEnd: (tapInfo) {

View file

@ -1,14 +1,13 @@
import 'dart:async';
import 'dart:developer';
import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
import 'package:mnemo_cards/features/packs/preview_packs_poller.dart';
import 'package:mnemo_cards/features/tests/test_state_holder.dart';
import 'package:mnemo_cards/features/tests/test_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';
@ -56,7 +55,22 @@ class TestManager extends ChangeNotifier {
(tests) async => pollTests(packId: packId)
.catchError((_) => Future.value(<TestDto>[])),
)
.where((test) => test.isNotEmpty);
.where((test) => test.isNotEmpty)
.distinct((p, n) {
if (p.length == n.length) {
for (int i = 0; i < p.length; i++) {
if (p[i].id != n[i].id ||
p[i].version != n[i].version ||
!ListEquality().equals(
p[i].statistics?.words.words,
n[i].statistics?.words.words,
)) {
return false;
}
}
}
return false;
});
}
TestQuestionState? getState(int id) => testHolder.testQuestionState(id);

View file

@ -66,10 +66,10 @@ class _TestProgressWidgetState extends State<TestProgressWidget> {
super.initState();
locator.testManager.pageController.removeListener(_updateDataListener);
locator.testManager.pageController.addListener(_updateDataListener);
timer = Stream.periodic(
Duration(seconds: 1),
(tick) => Duration(seconds: 300 - tick - 1),
).startWith(Duration(seconds: 300)).take(300);
// timer = Stream.periodic(
// Duration(seconds: 1),
// (tick) => Duration(seconds: 300 - tick - 1),
// ).startWith(Duration(seconds: 300)).take(300);
_updateData();
}

View file

@ -11,7 +11,6 @@ extension NullableIterableHelper<T> on Iterable<T?> {
}
extension ListHelper<T> on List<T> {
// List<T> shuffle([int? seed]) {
// Random r = Random(seed);
// var items = [...this];
@ -26,4 +25,37 @@ extension ListHelper<T> on List<T> {
// return items;
// }
}
void move(int from, int to) {
RangeError.checkValidIndex(from, this, "from", length);
RangeError.checkValidIndex(to, this, "to", length);
var element = this[from];
if (from < to) {
this.setRange(from, to, this, from + 1);
} else {
this.setRange(to + 1, from + 1, this, to);
}
this[to] = element;
}
void sortInOrder<P>(List<P> order, P valueGetter(T v)) => sort((p, n) {
final pVal = valueGetter(p);
final nVal = valueGetter(n);
final pIndex = order.indexOf(pVal);
final nIndex = order.indexOf(nVal);
if (pIndex == -1) {
return nIndex == -1
? () {
if (pVal is num) {
return pVal.compareTo(nVal as num);
} else if (pVal is String) {
return pVal.compareTo(nVal as String);
}
return pVal.toString().compareTo(nVal.toString());
}.call()
: 1;
} else if (nIndex == -1) {
return -1;
}
return pIndex.compareTo(nIndex);
});
}

View file

@ -11,6 +11,7 @@ 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';
import '../../features/card_swiper/src/card_swiper_controller.dart';
import '../../features/card_swiper/src/enums.dart';
import '../../main.dart';
import '../game_card_widget.dart';
@ -84,9 +85,7 @@ class CardGameMainWidget extends StatelessWidget {
cardsCount: length,
numberOfCardsDisplayed: numberOfCardsDisplayed,
allowedSwipeDirection:
AllowedSwipeDirection.symmetric(
horizontal: true,
),
AllowedSwipeDirection.all(),
scale: initialScale,
backCardOffset: Offset(0, cardHeight * 0.1 / 2),
isLoop: false,

View file

@ -52,9 +52,9 @@ class _CardGamePageState extends State<CardGamePage> {
@override
Widget build(BuildContext context) {
// todo move to controller
final timer = Stream.periodic(Duration(seconds: 1), (tick) {
return Duration(seconds: tick + 1);
}).asBroadcastStream().take(3600).startWith(Duration.zero);
// final timer = Stream.periodic(Duration(seconds: 1), (tick) {
// return Duration(seconds: tick + 1);
// }).asBroadcastStream().take(3600).startWith(Duration.zero);
return Scaffold(
body: PopScope(
@ -96,7 +96,6 @@ class _CardGamePageState extends State<CardGamePage> {
),
shuffledCards.length,
widget.color ?? borderGray,
timer: timer,
popText: widget.infinite ? 'к теме' : null,
key: ValueKey('CARDS PROGRESS'),
);

View file

@ -4,8 +4,12 @@ import 'package:collection/collection.dart';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.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/widgets/feedback_builder.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:reorderables/reorderables.dart';
import 'package:yandex_mobileads/mobile_ads.dart';
import '../../domain/router/app_router.gr.dart';
import '../../features/analytics/analytics.dart';
@ -21,6 +25,7 @@ import 'cards_view.dart';
import 'cards_view_controller.dart';
final _mainScrollController = ScrollController();
final _reorderableTests = GlobalKey();
class AvailablePack extends StatelessWidget {
final CardPackDto cardPack;
@ -65,24 +70,20 @@ class AvailablePack extends StatelessWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
StreamBuilder(
stream: _cardsViewController.asStream,
StreamBuilder<bool>(
stream: _cardsViewController.asStream
.map((s) => s.expanded),
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,
);
}
},
),
return _DownButton(
!(snapshot.data ?? false),
() async {
if (snapshot.hasData) {
_cardsViewController.setExpanded(
!snapshot.requireData,
);
}
},
key: ValueKey('DownButton'),
);
}),
_Button('shuffle.png', () async {
@ -168,16 +169,78 @@ class _TestsSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
final order = (globalSharedPreferences
.getString('tests_order_${packId}')
?.decodeList<String>() ??
<String>[])
.toList();
void saveOrder() {
globalSharedPreferences.setString(
'tests_order_${packId}',
order.encode(),
);
}
return StreamBuilder(
stream: locator.testManager.loadTestsStream(packId: packId),
builder: (context, snapshot) {
return Column(
children: [
if (snapshot.hasData)
...snapshot.data!.mapIndexed(
(int index, TestDto dto) => FadeInWidget(
delay: Duration(milliseconds: index * 200),
animationDuration: const Duration(milliseconds: 200),
final tests = snapshot.data ?? [];
if (tests.isEmpty) {
return SizedBox.shrink();
}
if (order.isEmpty) {
order.addAll(tests.map((t) => t.name));
saveOrder();
}
return FadeInWidget(
animationDuration: Duration(milliseconds: 400),
child: StatefulBuilder(builder: (context, setState) {
tests..sortInOrder(order, (t) => t.name);
return ReorderableListView.builder(
key: _reorderableTests,
itemExtent: 130.h + 2 * 5.h,
padding: EdgeInsets.symmetric(
horizontal: 10.w,
vertical: 5.h,
),
shrinkWrap: true,
scrollController: _mainScrollController,
// physics: NeverScrollableScrollPhysics(),
proxyDecorator: (_, index, animation) {
final dto = tests[index];
return Material(
elevation: 5,
borderRadius: BorderRadius.circular(16),
color: Colors.transparent,
child: _TestButton(
title: dto.name,
subtitle: null,
stats: dto.statistics,
color: dto.color?.asColor ?? borderGray,
time: dto.time,
timeSubtitle: dto.timeSubtitle,
onTap: () {},
),
);
},
onReorder: (int from, int to) {
if (to - from > 1) {
to--;
}
order.move(from, to);
saveOrder();
setState(() {});
},
itemCount: tests.length,
itemBuilder: (context, index) {
final dto = tests[index];
return Padding(
key: ValueKey(dto.id),
padding: EdgeInsets.symmetric(
vertical: 5.0.h,
),
child: _TestButton(
title: dto.name,
subtitle: null,
@ -192,9 +255,10 @@ class _TestsSection extends StatelessWidget {
);
},
),
),
),
],
);
},
);
}),
);
},
);
@ -261,6 +325,40 @@ class _Button extends StatelessWidget {
}
}
class _DownButton extends StatelessWidget {
final VoidCallback onTap;
final bool isDown;
_DownButton(bool this.isDown, this.onTap, {super.key});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Container(
width: 110.w,
height: 60.h,
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: borderGray),
borderRadius: BorderRadius.circular(16.0),
),
alignment: Alignment.center,
child: AnimatedRotation(
duration: Duration(milliseconds: 300),
turns: isDown ? 0.0 : 0.5,
child: Image.asset(
'icons/down.png',
width: 29.w,
// height: 29.h,
fit: BoxFit.scaleDown,
),
),
),
);
}
}
class _TestButton extends StatelessWidget {
final VoidCallback onTap;
final String title;
@ -283,99 +381,94 @@ class _TestButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.symmetric(
vertical: 5.0.h,
horizontal: 10.w,
),
child: InkWell(
onTap: onTap,
child: Container(
height: 120.h,
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: borderGray),
borderRadius: BorderRadius.circular(16.0),
),
padding: EdgeInsets.only(
top: 12.0.h,
left: 10.0.w,
right: 10.0.w,
bottom: 20.h,
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
title,
style: TextStyle(
fontSize: 25.sp,
fontWeight: FontWeight.w500,
height: 0.85,
return GestureDetector(
onTap: onTap,
child: Container(
height: 130.h,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: borderGray),
borderRadius: BorderRadius.circular(16.0),
),
padding: EdgeInsets.only(
top: 12.0.h,
left: 10.0.w,
right: 10.0.w,
bottom: 20.h,
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
title,
style: TextStyle(
fontSize: 25.sp,
fontWeight: FontWeight.w500,
height: 0.85,
),
),
if (subtitle != null)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
subtitle!,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w300,
height: 0.85,
),
),
),
if (subtitle != null)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
subtitle!,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w300,
height: 0.85,
),
),
),
Spacer(),
if (stats != null)
Padding(
padding: const EdgeInsets.only(right: 50.0),
child: Row(
children: [
Expanded(
child: HorizontalProgressWidget(
stats!.words.incorrect.round(),
stats!.words.total.round(),
color,
hasBorder: true,
),
Spacer(),
if (stats != null)
Padding(
padding: const EdgeInsets.only(right: 50.0),
child: Row(
children: [
Expanded(
child: HorizontalProgressWidget(
stats!.words.incorrect.round(),
stats!.words.total.round(),
color,
hasBorder: true,
),
],
),
),
],
),
],
),
),
],
),
Container(
width: 75.w,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (time != null)
Text(
time!,
style: TextStyle(
fontSize: 40.sp,
fontWeight: FontWeight.w400,
height: 0.85),
),
if (timeSubtitle != null)
Text(
timeSubtitle!,
style: TextStyle(
fontSize: 24.sp,
fontWeight: FontWeight.w400,
height: 0.85),
),
],
),
),
Container(
width: 75.w,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (time != null)
Text(
time!,
style: TextStyle(
fontSize: 40.sp,
fontWeight: FontWeight.w400,
height: 0.85),
),
if (timeSubtitle != null)
Text(
timeSubtitle!,
style: TextStyle(
fontSize: 24.sp,
fontWeight: FontWeight.w400,
height: 0.85),
),
],
),
],
),
),
],
),
),
);

View file

@ -1,23 +1,27 @@
import 'dart:async';
import 'package:auto_route/auto_route.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_common/mnemo_cards_common.dart';
import 'package:collection/collection.dart';
import 'package:reorderables/reorderables.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 '../feedback_builder.dart';
import '../game_card_widget.dart';
import 'cards_view_controller.dart';
class CardsView extends StatefulWidget {
final CardPackDto cardPack;
final CardsViewController controller;
final List<int> cardsOrder = [];
CardsView(this.controller, this.cardPack);
@ -150,34 +154,42 @@ class _CardsViewState extends State<CardsView>
((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),
child: Builder(builder: (context) {
return ReorderableWrap(
key: wrapKey,
onReorder: (int from, int to) {
controller.onOrderChanged(from, to);
},
buildDraggableFeedback: FeedbackBuilder.simple,
padding: EdgeInsets.symmetric(
horizontal: (cardWidth - imageWidth - 1),
vertical: (cardWidth - imageWidth - 1),
),
runSpacing: (cardWidth - imageWidth - 1) * 2,
spacing: (cardWidth - imageWidth - 1) * 2,
children: List.generate(packCards.length, (index) {
final card = packCards[index];
final memoryImage = card.memoryImage;
if (memoryImage == null) {
return SizedBox();
}
return Container(
height: 2 * imageWidth - cardWidth * 2 / 3 + 2,
width: 2 * imageWidth - cardWidth + 2,
alignment: Alignment.topCenter,
child: Container(
height: imageWidth * 4 / 3,
child: _CardBuilder(
cardPack,
card,
memoryImage,
(card) => onCardTap(packCards, card),
),
),
),
);
}),
),
);
}),
);
}),
),
),
],

View file

@ -3,6 +3,8 @@ import 'dart:math';
import 'package:flutter/widgets.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_common/mnemo_cards_common.dart';
import 'package:rxdart/rxdart.dart';
@ -13,6 +15,10 @@ class CardsViewController extends ChangeNotifier {
bool _favorite = false;
double imageWidth = 0.0;
List<GameCardDto> cards = [];
List<int> allCardsOrder = [];
List<int> favoriteCardsOrder = [];
List<int> get cardsOrder => _favorite ? favoriteCardsOrder : allCardsOrder;
final CardPackDto cardPack;
@ -57,6 +63,61 @@ class CardsViewController extends ChangeNotifier {
}
}
Future<void> _saveCardsOrder() async {
if (_favorite) {
await globalSharedPreferences.setString(
'sort_fav_${cardPack.id}',
favoriteCardsOrder.encode(),
);
} else {
await globalSharedPreferences.setString(
'sort_${cardPack.id}',
allCardsOrder.encode(),
);
}
}
void _loadCardsOrder() {
favoriteCardsOrder = globalSharedPreferences
.getString(
'sort_fav_${cardPack.id}',
)
?.decodeList<int>() ??
[];
allCardsOrder = globalSharedPreferences
.getString(
'sort_${cardPack.id}',
)
?.decodeList<int>() ??
[];
if (allCardsOrder.isEmpty) {
allCardsOrder = cardPack.cards.map((c) => c.id).toList();
}
if (favoriteCardsOrder.isEmpty) {
favoriteCardsOrder = allCardsOrder.toList();
}
}
void setCardsOrder(List<int> order) {
if (_favorite) {
favoriteCardsOrder = order.toList();
} else {
allCardsOrder = order.toList();
}
_saveCardsOrder();
_updateCards();
}
void onOrderChanged(int from, int to) {
if (_favorite) {
favoriteCardsOrder.move(from, to);
} else {
allCardsOrder.move(from, to);
}
_saveCardsOrder();
_updateCards();
}
void setFavorite(bool favorite) {
_favorite = favorite;
_updateCards();
@ -75,11 +136,23 @@ class CardsViewController extends ChangeNotifier {
CardsViewController(this.cardPack) {
addListener(_addToStream);
_loadCardsOrder();
_updateCards();
}
void _updateCards() {
cards = _filterPackCards();
cards
..sort((p, n) {
final pIndex = cardsOrder.indexOf(p.id);
final nIndex = cardsOrder.indexOf(n.id);
if (pIndex == -1) {
return nIndex == -1 ? p.id.compareTo(n.id) : 1;
} else if (nIndex == -1) {
return -1;
}
return pIndex.compareTo(nIndex);
});
notifyListeners();
}

View file

@ -11,6 +11,7 @@ class FadeInWidget extends StatelessWidget {
this.delay = const Duration(milliseconds: 200),
this.future,
this.animationDuration = const Duration(milliseconds: 200),
super.key,
}) : assert(future != null || delay != null);
@override

View file

@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
class FeedbackBuilder {
static Widget tests(Widget child, int index, Animation<double> animation) =>
Transform(
transform: new Matrix4.rotationZ(0),
child: Material(
child: child,
elevation: 1.0,
color: Colors.transparent,
borderRadius: BorderRadius.circular(16.0),
),
);
static Widget simple(
BuildContext context,
BoxConstraints constraints,
Widget child,
) {
return Transform(
transform: new Matrix4.rotationZ(0.05),
// alignment: FractionalOffset.topLeft,
child: Material(
child: child,
elevation: 6.0,
color: Colors.transparent,
borderRadius: BorderRadius.circular(12.0),
),
);
}
}

View file

@ -168,6 +168,7 @@ class _PackButton4 extends StatelessWidget {
),
);
},
borderRadius: BorderRadius.circular(12.0),
child: Container(
margin: EdgeInsets.all(4.0.h),
alignment: Alignment.center,