Last active
June 3, 2023 15:07
-
-
Save slightfoot/d098812e2228b21d60b25543f4425ae1 to your computer and use it in GitHub Desktop.
Flip Cards Example - 17th May 2023 & 24th May 2023 & 31st May 2023 - HumpdayQandA (Incomplete) - by Simon Lightfoot
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// MIT License | |
// | |
// Copyright (c) 2023 Simon Lightfoot | |
// | |
// Permission is hereby granted, free of charge, to any person obtaining a copy | |
// of this software and associated documentation files (the "Software"), to deal | |
// in the Software without restriction, including without limitation the rights | |
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
// copies of the Software, and to permit persons to whom the Software is | |
// furnished to do so, subject to the following conditions: | |
// | |
// The above copyright notice and this permission notice shall be included in all | |
// copies or substantial portions of the Software. | |
// | |
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
// SOFTWARE. | |
// | |
import 'dart:math' as math; | |
import 'package:confetti/confetti.dart'; | |
import 'package:flutter/foundation.dart'; | |
import 'package:flutter/material.dart'; | |
// matching: flip cards until all matched | |
const backgroundColor = Color(0xFF1A1A1A); | |
void main() { | |
runApp(FlipApp(appState: AppState())); | |
} | |
enum GameState { | |
intro, | |
playing, | |
completed, | |
} | |
class AppState { | |
final gameState = ValueNotifier<GameState>(GameState.intro); | |
final allCards = ValueNotifier<List<CardData>>([]); | |
final score = ValueNotifier<int>(0); | |
final iconSelection = [ | |
Icons.add_box, | |
Icons.verified_user, | |
Icons.check, | |
Icons.group, | |
Icons.ac_unit, | |
Icons.stop_circle_outlined, | |
Icons.folder_delete, | |
Icons.delete_sweep_rounded, | |
Icons.key, | |
Icons.access_alarm_outlined, | |
Icons.discord, | |
Icons.facebook, | |
]; | |
bool get isGameComplete => allCards.value.every((card) => card.matched); | |
void startGame() { | |
const count = 12; | |
final rand = math.Random(); | |
final icons = List.of(iconSelection)..shuffle(rand); | |
final cardSet = List.generate(count ~/ 2, (int index) { | |
return CardData( | |
rand.nextInt(100), | |
icons[index], | |
matched: false, | |
flipped: false, | |
); | |
}); | |
score.value = 0; | |
allCards.value = [ | |
...cardSet, | |
...cardSet.map((el) => el.copyWith()), | |
]..shuffle(rand); | |
gameState.value = GameState.playing; | |
} | |
CardData replaceCard(CardData data, CardData replacement) { | |
final localCards = List.of(allCards.value); | |
localCards[allCards.value.indexOf(data)] = replacement; | |
allCards.value = localCards; | |
return replacement; | |
} | |
Future<void> checkCard(CardData card) async { | |
// Get all flipped cards | |
final flippedCards = allCards.value.where((card) => card.flipped).toList(); | |
// Do we already have two cards flipped? dont flip the next | |
if (flippedCards.length == 2) { | |
return; | |
} | |
// Flip the selected card | |
card = replaceCard(card, card.copyWith(flipped: true)); | |
flippedCards.add(card); | |
// Do we now have enough cards to match? | |
if (flippedCards.length < 2) { | |
return; | |
} | |
// Do these cards match? | |
final first = flippedCards[0]; | |
final second = flippedCards[1]; | |
if (first.icon == second.icon) { | |
print('matched'); | |
score.value += (first.score + second.score); | |
replaceCard(first, first.copyWith(matched: true)); | |
replaceCard(second, second.copyWith(matched: true)); | |
if (isGameComplete) { | |
gameState.value = GameState.completed; | |
} | |
} else { | |
await Future.delayed(const Duration(milliseconds: 1000)); | |
replaceCard(first, first.copyWith(flipped: false)); | |
replaceCard(second, second.copyWith(flipped: false)); | |
} | |
} | |
} | |
mixin AppStateMixin<T extends StatefulWidget> on State<T> { | |
late final appState = FlipApp.appStateOf(context); | |
} | |
class FlipApp extends StatelessWidget { | |
const FlipApp({ | |
super.key, | |
required this.appState, | |
}); | |
final AppState appState; | |
static AppState appStateOf(BuildContext context) { | |
return context.findAncestorWidgetOfExactType<FlipApp>()!.appState; | |
} | |
@override | |
Widget build(BuildContext context) { | |
return MaterialApp( | |
debugShowCheckedModeBanner: false, | |
theme: ThemeData( | |
brightness: Brightness.dark, | |
primarySwatch: Colors.orange, | |
highlightColor: Colors.yellow.withOpacity(0.4), | |
splashColor: Colors.yellow, | |
), | |
home: ValueListenableBuilder( | |
valueListenable: appState.gameState, | |
builder: (BuildContext context, GameState state, _) { | |
return switch (state) { | |
GameState.intro => const IntroScreen(), | |
GameState.playing || GameState.completed => const PlayingScreen(), | |
}; | |
}, | |
), | |
); | |
} | |
} | |
@immutable | |
class IntroScreen extends StatefulWidget { | |
const IntroScreen({super.key}); | |
@override | |
State<IntroScreen> createState() => _IntroScreenState(); | |
} | |
class _IntroScreenState extends State<IntroScreen> with AppStateMixin { | |
@override | |
Widget build(BuildContext context) { | |
final theme = Theme.of(context); | |
return Material( | |
color: backgroundColor, | |
child: Align( | |
alignment: const Alignment(0.0, -0.33), | |
child: Column( | |
mainAxisSize: MainAxisSize.min, | |
children: [ | |
const Text( | |
'Card Flipper!', | |
style: TextStyle(fontSize: 54.0), | |
), | |
const Text( | |
'Flip all the cards to find all the matches.', | |
style: TextStyle(fontSize: 24.0), | |
), | |
const SizedBox(height: 64.0), | |
TextButton( | |
onPressed: appState.startGame, | |
style: TextButton.styleFrom( | |
textStyle: theme.textTheme.headlineLarge, | |
), | |
child: const Text('Start Game'), | |
), | |
const SizedBox(height: 64.0), | |
], | |
), | |
), | |
); | |
} | |
} | |
@immutable | |
class PlayingScreen extends StatefulWidget { | |
const PlayingScreen({super.key}); | |
@override | |
State<PlayingScreen> createState() => _PlayingScreenState(); | |
} | |
class _PlayingScreenState extends State<PlayingScreen> with AppStateMixin { | |
late ConfettiController _confettiLeft; | |
late ConfettiController _confettiRight; | |
@override | |
void initState() { | |
super.initState(); | |
_confettiLeft = ConfettiController(duration: const Duration(seconds: 10)); | |
_confettiRight = ConfettiController(duration: const Duration(seconds: 10)); | |
appState.gameState.addListener(_onGameStateChanged); | |
} | |
void _onGameStateChanged() { | |
if (appState.gameState.value == GameState.completed) { | |
_confettiLeft.play(); | |
_confettiRight.play(); | |
} | |
} | |
void _onNextGamePressed() { | |
appState.gameState.value = GameState.intro; | |
} | |
@override | |
void dispose() { | |
appState.gameState.removeListener(_onGameStateChanged); | |
_confettiLeft.dispose(); | |
_confettiRight.dispose(); | |
super.dispose(); | |
} | |
@override | |
Widget build(BuildContext context) { | |
final theme = Theme.of(context); | |
return Material( | |
color: backgroundColor, | |
child: Stack( | |
fit: StackFit.expand, | |
children: [ | |
Column( | |
children: [ | |
Expanded( | |
child: ValueListenableBuilder( | |
valueListenable: appState.allCards, | |
builder: (BuildContext context, List<CardData> allCards, | |
Widget? child) { | |
return MultiCardLayout( | |
cards: [ | |
for (final data in allCards) // | |
Card(data: data), | |
], | |
); | |
}, | |
), | |
), | |
SizedBox( | |
height: 128.0, | |
child: Padding( | |
padding: const EdgeInsets.symmetric(horizontal: 24.0), | |
child: AnimatedBuilder( | |
animation: Listenable.merge([ | |
appState.score, | |
appState.gameState, | |
]), | |
builder: (BuildContext context, Widget? child) { | |
return Row( | |
children: [ | |
Expanded( | |
child: Text( | |
'Score: ${appState.score.value}', | |
style: theme.textTheme.headlineLarge, | |
), | |
), | |
if (appState.gameState.value == | |
GameState.completed) // | |
TextButton( | |
onPressed: _onNextGamePressed, | |
style: TextButton.styleFrom( | |
textStyle: theme.textTheme.headlineLarge, | |
), | |
child: const Text('Next Game'), | |
), | |
], | |
); | |
}, | |
), | |
), | |
), | |
], | |
), | |
Align( | |
alignment: Alignment.bottomLeft, | |
child: ConfettiWidget( | |
confettiController: _confettiLeft, | |
blastDirection: math.pi * -0.4, | |
emissionFrequency: 0.02, | |
numberOfParticles: 100, | |
maxBlastForce: 200, | |
minBlastForce: 120, | |
gravity: 0.2, | |
), | |
), | |
Align( | |
alignment: Alignment.bottomRight, | |
child: ConfettiWidget( | |
confettiController: _confettiRight, | |
blastDirection: -math.pi / 1.75, | |
emissionFrequency: 0.02, | |
numberOfParticles: 100, | |
maxBlastForce: 200, | |
minBlastForce: 120, | |
gravity: 0.2, | |
), | |
), | |
], | |
), | |
); | |
} | |
} | |
@immutable | |
class MultiCardLayout extends StatelessWidget { | |
const MultiCardLayout({ | |
super.key, | |
required this.cards, | |
}); | |
final List<Card> cards; | |
@override | |
Widget build(BuildContext context) { | |
return CustomMultiChildLayout( | |
delegate: _MultiCardLayoutDelegate( | |
cards: cards, | |
cols: 3, | |
), | |
children: [ | |
for (final (index, card) in cards.indexed) // | |
LayoutId( | |
id: index, | |
child: Padding( | |
padding: const EdgeInsets.all(8.0), | |
child: Center( | |
child: card, | |
), | |
), | |
), | |
], | |
); | |
} | |
} | |
class _MultiCardLayoutDelegate extends MultiChildLayoutDelegate { | |
_MultiCardLayoutDelegate({ | |
required this.cards, | |
required this.cols, | |
}); | |
final List<Card> cards; | |
final int cols; | |
@override | |
bool shouldRelayout(covariant _MultiCardLayoutDelegate oldDelegate) { | |
return listEquals(cards, oldDelegate.cards) == false; | |
} | |
@override | |
void performLayout(Size size) { | |
final rows = cards.length ~/ cols; | |
final childConstraints = BoxConstraints.tightFor( | |
width: size.width / cols, | |
height: size.height / rows, | |
); | |
for (int row = 0; row < rows; row++) { | |
for (int col = 0; col < cols; col++) { | |
final childId = row * cols + col; | |
layoutChild(childId, childConstraints); | |
positionChild( | |
childId, | |
Offset( | |
col * childConstraints.maxWidth, | |
row * childConstraints.maxHeight, | |
), | |
); | |
} | |
} | |
} | |
} | |
@immutable | |
class Card extends StatefulWidget { | |
const Card({ | |
super.key, | |
required this.data, | |
}); | |
final CardData data; | |
@override | |
State<Card> createState() => _CardState(); | |
} | |
class _CardState extends State<Card> | |
with AppStateMixin, SingleTickerProviderStateMixin { | |
late final AnimationController _controller; | |
bool showBack = true; | |
@override | |
void initState() { | |
super.initState(); | |
_controller = AnimationController( | |
vsync: this, | |
duration: const Duration(milliseconds: 250), | |
value: widget.data.showFront ? 1.0 : 0.0, | |
); | |
_controller.addListener(_onAnimationUpdate); | |
} | |
@override | |
void didUpdateWidget(covariant Card oldWidget) { | |
super.didUpdateWidget(oldWidget); | |
if (widget.data.showFront) { | |
_controller.forward(); | |
} else { | |
_controller.reverse(); | |
} | |
} | |
void _onTapCard() { | |
if (_controller.status == AnimationStatus.dismissed) { | |
_controller.forward().whenComplete(() => appState.checkCard(widget.data)); | |
} | |
} | |
void _onAnimationUpdate() { | |
final value = _controller.value; | |
if (value < 0.5) { | |
if (!showBack) { | |
setState(() => showBack = true); | |
} | |
} else { | |
if (showBack) { | |
setState(() => showBack = false); | |
} | |
} | |
} | |
@override | |
void dispose() { | |
_controller.removeListener(_onAnimationUpdate); | |
_controller.dispose(); | |
super.dispose(); | |
} | |
@override | |
Widget build(BuildContext context) { | |
final theme = Theme.of(context); | |
const border = RoundedRectangleBorder( | |
borderRadius: BorderRadius.all(Radius.circular(12.0)), | |
); | |
return AnimatedBuilder( | |
animation: _controller, | |
builder: (BuildContext context, Widget? child) { | |
return Transform( | |
alignment: Alignment.center, | |
transform: Matrix4Tween( | |
begin: Matrix4.identity(), | |
end: Matrix4.diagonal3Values(-1.0, 1.0, 1.0), | |
).evaluate(_controller), | |
child: child, | |
); | |
}, | |
child: Material( | |
color: showBack // | |
? theme.colorScheme.primary | |
: Colors.white, | |
shape: border, | |
child: InkWell( | |
onTap: widget.data.matched == false ? _onTapCard : null, | |
customBorder: border, | |
child: AspectRatio( | |
aspectRatio: 0.7, | |
child: Padding( | |
padding: const EdgeInsets.all(24.0), | |
child: Opacity( | |
opacity: showBack ? 0.0 : 1.0, | |
child: FittedBox( | |
fit: BoxFit.contain, | |
child: Transform.scale( | |
scaleX: -1.0, | |
child: Icon( | |
widget.data.icon, | |
color: Colors.black, | |
), | |
), | |
), | |
), | |
), | |
), | |
), | |
), | |
); | |
} | |
} | |
class CardData { | |
const CardData( | |
this.score, | |
this.icon, { | |
required this.matched, | |
required this.flipped, | |
}); | |
final int score; | |
final IconData icon; | |
final bool matched; | |
final bool flipped; | |
bool get showFront => matched || flipped; | |
CardData copyWith({ | |
int? score, | |
IconData? icon, | |
bool? matched, | |
bool? flipped, | |
}) { | |
return CardData( | |
score ?? this.score, | |
icon ?? this.icon, | |
matched: matched ?? this.matched, | |
flipped: flipped ?? this.flipped, | |
); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment