Skip to content

Instantly share code, notes, and snippets.

@slightfoot
Last active June 3, 2023 15:07
Show Gist options
  • Save slightfoot/d098812e2228b21d60b25543f4425ae1 to your computer and use it in GitHub Desktop.
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
// 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