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

View file

@ -1,14 +1,13 @@
import 'dart:async'; import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'package:collection/collection.dart';
import 'package:flutter/widgets.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_state_holder.dart';
import 'package:mnemo_cards/features/tests/test_widgets/test_image.dart'; import 'package:mnemo_cards/features/tests/test_widgets/test_image.dart';
import 'package:mnemo_cards/features/packs/pack_cache_manager.dart'; import 'package:mnemo_cards/features/packs/pack_cache_manager.dart';
import 'package:mnemo_cards/main.dart'; import 'package:mnemo_cards/main.dart';
import 'package:mnemo_cards/theme/themes.dart'; import 'package:mnemo_cards/theme/themes.dart';
import 'package:mnemo_cards/utils/iterable_helper.dart';
import 'package:mnemo_cards/utils/string_helper.dart'; import 'package:mnemo_cards/utils/string_helper.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:rxdart/rxdart.dart'; import 'package:rxdart/rxdart.dart';
@ -56,7 +55,22 @@ class TestManager extends ChangeNotifier {
(tests) async => pollTests(packId: packId) (tests) async => pollTests(packId: packId)
.catchError((_) => Future.value(<TestDto>[])), .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); TestQuestionState? getState(int id) => testHolder.testQuestionState(id);

View file

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

View file

@ -11,7 +11,6 @@ extension NullableIterableHelper<T> on Iterable<T?> {
} }
extension ListHelper<T> on List<T> { extension ListHelper<T> on List<T> {
// List<T> shuffle([int? seed]) { // List<T> shuffle([int? seed]) {
// Random r = Random(seed); // Random r = Random(seed);
// var items = [...this]; // var items = [...this];
@ -26,4 +25,37 @@ extension ListHelper<T> on List<T> {
// return items; // 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/allowed_swipe_direction.dart';
import '../../features/card_swiper/src/card_swiper.dart'; import '../../features/card_swiper/src/card_swiper.dart';
import '../../features/card_swiper/src/card_swiper_controller.dart'; import '../../features/card_swiper/src/card_swiper_controller.dart';
import '../../features/card_swiper/src/enums.dart';
import '../../main.dart'; import '../../main.dart';
import '../game_card_widget.dart'; import '../game_card_widget.dart';
@ -84,9 +85,7 @@ class CardGameMainWidget extends StatelessWidget {
cardsCount: length, cardsCount: length,
numberOfCardsDisplayed: numberOfCardsDisplayed, numberOfCardsDisplayed: numberOfCardsDisplayed,
allowedSwipeDirection: allowedSwipeDirection:
AllowedSwipeDirection.symmetric( AllowedSwipeDirection.all(),
horizontal: true,
),
scale: initialScale, scale: initialScale,
backCardOffset: Offset(0, cardHeight * 0.1 / 2), backCardOffset: Offset(0, cardHeight * 0.1 / 2),
isLoop: false, isLoop: false,

View file

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

View file

@ -4,8 +4,12 @@ import 'package:collection/collection.dart';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.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/fade_in_widget.dart';
import 'package:mnemo_cards/widgets/feedback_builder.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:reorderables/reorderables.dart';
import 'package:yandex_mobileads/mobile_ads.dart'; import 'package:yandex_mobileads/mobile_ads.dart';
import '../../domain/router/app_router.gr.dart'; import '../../domain/router/app_router.gr.dart';
import '../../features/analytics/analytics.dart'; import '../../features/analytics/analytics.dart';
@ -21,6 +25,7 @@ import 'cards_view.dart';
import 'cards_view_controller.dart'; import 'cards_view_controller.dart';
final _mainScrollController = ScrollController(); final _mainScrollController = ScrollController();
final _reorderableTests = GlobalKey();
class AvailablePack extends StatelessWidget { class AvailablePack extends StatelessWidget {
final CardPackDto cardPack; final CardPackDto cardPack;
@ -65,24 +70,20 @@ class AvailablePack extends StatelessWidget {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
StreamBuilder( StreamBuilder<bool>(
stream: _cardsViewController.asStream, stream: _cardsViewController.asStream
.map((s) => s.expanded),
builder: (context, snapshot) { builder: (context, snapshot) {
return AnimatedRotation( return _DownButton(
duration: Duration(milliseconds: 300), !(snapshot.data ?? false),
turns: (snapshot.data?.expanded ?? false)
? 0.5
: 0.0,
child: _Button(
'down.png',
() async { () async {
if (snapshot.hasData) { if (snapshot.hasData) {
_cardsViewController.setExpanded( _cardsViewController.setExpanded(
!snapshot.requireData.expanded, !snapshot.requireData,
); );
} }
}, },
), key: ValueKey('DownButton'),
); );
}), }),
_Button('shuffle.png', () async { _Button('shuffle.png', () async {
@ -168,16 +169,78 @@ class _TestsSection extends StatelessWidget {
@override @override
Widget build(BuildContext context) { 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( return StreamBuilder(
stream: locator.testManager.loadTestsStream(packId: packId), stream: locator.testManager.loadTestsStream(packId: packId),
builder: (context, snapshot) { builder: (context, snapshot) {
return Column( final tests = snapshot.data ?? [];
children: [ if (tests.isEmpty) {
if (snapshot.hasData) return SizedBox.shrink();
...snapshot.data!.mapIndexed( }
(int index, TestDto dto) => FadeInWidget(
delay: Duration(milliseconds: index * 200), if (order.isEmpty) {
animationDuration: const Duration(milliseconds: 200), 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( child: _TestButton(
title: dto.name, title: dto.name,
subtitle: null, 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 { class _TestButton extends StatelessWidget {
final VoidCallback onTap; final VoidCallback onTap;
final String title; final String title;
@ -283,15 +381,11 @@ class _TestButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return GestureDetector(
padding: EdgeInsets.symmetric(
vertical: 5.0.h,
horizontal: 10.w,
),
child: InkWell(
onTap: onTap, onTap: onTap,
child: Container( child: Container(
height: 120.h, height: 130.h,
alignment: Alignment.center,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
border: Border.all(color: borderGray), border: Border.all(color: borderGray),
@ -377,7 +471,6 @@ class _TestButton extends StatelessWidget {
], ],
), ),
), ),
),
); );
} }
} }

View file

@ -1,23 +1,27 @@
import 'dart:async'; import 'dart:async';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:mnemo_cards/features/packs/images_holder.dart'; import 'package:mnemo_cards/features/packs/images_holder.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:reorderables/reorderables.dart';
import 'package:rxdart/rxdart.dart'; import 'package:rxdart/rxdart.dart';
import '../../di/locator.dart'; import '../../di/locator.dart';
import '../../utils/string_helper.dart'; import '../../utils/string_helper.dart';
import '../../domain/router/app_router.gr.dart'; import '../../domain/router/app_router.gr.dart';
import '../../main.dart'; import '../../main.dart';
import '../feedback_builder.dart';
import '../game_card_widget.dart'; import '../game_card_widget.dart';
import 'cards_view_controller.dart'; import 'cards_view_controller.dart';
class CardsView extends StatefulWidget { class CardsView extends StatefulWidget {
final CardPackDto cardPack; final CardPackDto cardPack;
final CardsViewController controller; final CardsViewController controller;
final List<int> cardsOrder = [];
CardsView(this.controller, this.cardPack); CardsView(this.controller, this.cardPack);
@ -150,8 +154,19 @@ class _CardsViewState extends State<CardsView>
((packCards.length / 3).ceil() * 4 / 3 * cardWidth - ((packCards.length / 3).ceil() * 4 / 3 * cardWidth -
cardWidth * 4 / 3) * cardWidth * 4 / 3) *
animation.value, animation.value,
child: Wrap( child: Builder(builder: (context) {
return ReorderableWrap(
key: wrapKey, 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) { children: List.generate(packCards.length, (index) {
final card = packCards[index]; final card = packCards[index];
final memoryImage = card.memoryImage; final memoryImage = card.memoryImage;
@ -159,12 +174,8 @@ class _CardsViewState extends State<CardsView>
return SizedBox(); return SizedBox();
} }
return Container( return Container(
height: cardWidth * 4 / 3, height: 2 * imageWidth - cardWidth * 2 / 3 + 2,
width: cardWidth, width: 2 * imageWidth - cardWidth + 2,
padding: EdgeInsets.symmetric(
horizontal: cardWidth - imageWidth - 1,
vertical: cardWidth - imageWidth - 1,
),
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
child: Container( child: Container(
height: imageWidth * 4 / 3, height: imageWidth * 4 / 3,
@ -177,7 +188,8 @@ class _CardsViewState extends State<CardsView>
), ),
); );
}), }),
), );
}),
), ),
), ),
], ],

View file

@ -3,6 +3,8 @@ import 'dart:math';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:mnemo_cards/features/packs/images_holder.dart'; import 'package:mnemo_cards/features/packs/images_holder.dart';
import 'package:mnemo_cards/main.dart';
import 'package:mnemo_cards/utils/iterable_helper.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:rxdart/rxdart.dart'; import 'package:rxdart/rxdart.dart';
@ -13,6 +15,10 @@ class CardsViewController extends ChangeNotifier {
bool _favorite = false; bool _favorite = false;
double imageWidth = 0.0; double imageWidth = 0.0;
List<GameCardDto> cards = []; List<GameCardDto> cards = [];
List<int> allCardsOrder = [];
List<int> favoriteCardsOrder = [];
List<int> get cardsOrder => _favorite ? favoriteCardsOrder : allCardsOrder;
final CardPackDto cardPack; 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) { void setFavorite(bool favorite) {
_favorite = favorite; _favorite = favorite;
_updateCards(); _updateCards();
@ -75,11 +136,23 @@ class CardsViewController extends ChangeNotifier {
CardsViewController(this.cardPack) { CardsViewController(this.cardPack) {
addListener(_addToStream); addListener(_addToStream);
_loadCardsOrder();
_updateCards(); _updateCards();
} }
void _updateCards() { void _updateCards() {
cards = _filterPackCards(); 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(); notifyListeners();
} }

View file

@ -11,6 +11,7 @@ class FadeInWidget extends StatelessWidget {
this.delay = const Duration(milliseconds: 200), this.delay = const Duration(milliseconds: 200),
this.future, this.future,
this.animationDuration = const Duration(milliseconds: 200), this.animationDuration = const Duration(milliseconds: 200),
super.key,
}) : assert(future != null || delay != null); }) : assert(future != null || delay != null);
@override @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( child: Container(
margin: EdgeInsets.all(4.0.h), margin: EdgeInsets.all(4.0.h),
alignment: Alignment.center, alignment: Alignment.center,