1
0
mirror of https://github.com/flutter/samples.git synced 2025-11-10 14:58:34 +00:00

Add game_template (#1180)

Adds a template / sample for games built in Flutter, with all the bells and whistles, like ads, in-app purchases, audio, main menu, settings, and so on.

Co-authored-by: Parker Lougheed
Co-authored-by: Shams Zakhour
This commit is contained in:
Filip Hracek
2022-05-10 15:08:43 +02:00
committed by GitHub
parent 5143bcf302
commit daa024a829
208 changed files with 8993 additions and 0 deletions

View File

@@ -0,0 +1,62 @@
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'package:flutter/foundation.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'preloaded_banner_ad.dart';
/// Allows showing ads. A facade for `package:google_mobile_ads`.
class AdsController {
final MobileAds _instance;
PreloadedBannerAd? _preloadedAd;
/// Creates an [AdsController] that wraps around a [MobileAds] [instance].
///
/// Example usage:
///
/// var controller = AdsController(MobileAds.instance);
AdsController(MobileAds instance) : _instance = instance;
void dispose() {
_preloadedAd?.dispose();
}
/// Initializes the injected [MobileAds.instance].
Future<void> initialize() async {
await _instance.initialize();
}
/// Starts preloading an ad to be used later.
///
/// The work doesn't start immediately so that calling this doesn't have
/// adverse effects (jank) during start of a new screen.
void preloadAd() {
// TODO: When ready, change this to the Ad Unit IDs provided by AdMob.
// The current values are AdMob's sample IDs.
final adUnitId = defaultTargetPlatform == TargetPlatform.android
? 'ca-app-pub-3940256099942544/6300978111'
// iOS
: 'ca-app-pub-3940256099942544/2934735716';
_preloadedAd =
PreloadedBannerAd(size: AdSize.mediumRectangle, adUnitId: adUnitId);
// Wait a bit so that calling at start of a new screen doesn't have
// adverse effects on performance.
Future<void>.delayed(const Duration(seconds: 1)).then((_) {
return _preloadedAd!.load();
});
}
/// Allows caller to take ownership of a [PreloadedBannerAd].
///
/// If this method returns a non-null value, then the caller is responsible
/// for disposing of the loaded ad.
PreloadedBannerAd? takePreloadedAd() {
final ad = _preloadedAd;
_preloadedAd = null;
return ad;
}
}

View File

@@ -0,0 +1,205 @@
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import 'ads_controller.dart';
import 'preloaded_banner_ad.dart';
/// Displays a banner ad that conforms to the widget's size in the layout,
/// and reloads the ad when the user changes orientation.
///
/// Do not use this widget on platforms that AdMob currently doesn't support.
/// For example:
///
/// ```dart
/// if (kIsWeb) {
/// return Text('No ads here! (Yet.)');
/// } else {
/// return MyBannerAd();
/// }
/// ```
///
/// This widget is adapted from pkg:google_mobile_ads's example code,
/// namely the `anchored_adaptive_example.dart` file:
/// https://github.com/googleads/googleads-mobile-flutter/blob/main/packages/google_mobile_ads/example/lib/anchored_adaptive_example.dart
class BannerAdWidget extends StatefulWidget {
const BannerAdWidget({Key? key}) : super(key: key);
@override
_BannerAdWidgetState createState() => _BannerAdWidgetState();
}
class _BannerAdWidgetState extends State<BannerAdWidget> {
static final _log = Logger('BannerAdWidget');
static const useAnchoredAdaptiveSize = false;
BannerAd? _bannerAd;
_LoadingState _adLoadingState = _LoadingState.initial;
late Orientation _currentOrientation;
@override
Widget build(BuildContext context) {
return OrientationBuilder(
builder: (context, orientation) {
if (_currentOrientation == orientation &&
_bannerAd != null &&
_adLoadingState == _LoadingState.loaded) {
_log.info(() => 'We have everything we need. Showing the ad '
'${_bannerAd.hashCode} now.');
return SizedBox(
width: _bannerAd!.size.width.toDouble(),
height: _bannerAd!.size.height.toDouble(),
child: AdWidget(ad: _bannerAd!),
);
}
// Reload the ad if the orientation changes.
if (_currentOrientation != orientation) {
_log.info('Orientation changed');
_currentOrientation = orientation;
_loadAd();
}
return const SizedBox();
},
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_currentOrientation = MediaQuery.of(context).orientation;
}
@override
void dispose() {
_log.info('disposing ad');
_bannerAd?.dispose();
super.dispose();
}
@override
void initState() {
super.initState();
final adsController = context.read<AdsController>();
final ad = adsController.takePreloadedAd();
if (ad != null) {
_log.info("A preloaded banner was supplied. Using it.");
_showPreloadedAd(ad);
} else {
_loadAd();
}
}
/// Load (another) ad, disposing of the current ad if there is one.
Future<void> _loadAd() async {
if (!mounted) return;
_log.info('_loadAd() called.');
if (_adLoadingState == _LoadingState.loading ||
_adLoadingState == _LoadingState.disposing) {
_log.info('An ad is already being loaded or disposed. Aborting.');
return;
}
_adLoadingState = _LoadingState.disposing;
await _bannerAd?.dispose();
_log.fine('_bannerAd disposed');
setState(() {
_bannerAd = null;
_adLoadingState = _LoadingState.loading;
});
AdSize size;
if (useAnchoredAdaptiveSize) {
final AnchoredAdaptiveBannerAdSize? adaptiveSize =
await AdSize.getCurrentOrientationAnchoredAdaptiveBannerAdSize(
MediaQuery.of(context).size.width.truncate());
if (adaptiveSize == null) {
_log.warning('Unable to get height of anchored banner.');
size = AdSize.banner;
} else {
size = adaptiveSize;
}
} else {
size = AdSize.mediumRectangle;
}
assert(Platform.isAndroid || Platform.isIOS,
'AdMob currently does not support ${Platform.operatingSystem}');
_bannerAd = BannerAd(
// This is a test ad unit ID from
// https://developers.google.com/admob/android/test-ads. When ready,
// you replace this with your own, production ad unit ID,
// created in https://apps.admob.com/.
adUnitId: Theme.of(context).platform == TargetPlatform.android
? 'ca-app-pub-3940256099942544/6300978111'
: 'ca-app-pub-3940256099942544/2934735716',
size: size,
request: const AdRequest(),
listener: BannerAdListener(
onAdLoaded: (ad) {
_log.info(() => 'Ad loaded: ${ad.responseInfo}');
setState(() {
// When the ad is loaded, get the ad size and use it to set
// the height of the ad container.
_bannerAd = ad as BannerAd;
_adLoadingState = _LoadingState.loaded;
});
},
onAdFailedToLoad: (ad, error) {
_log.warning('Banner failedToLoad: $error');
ad.dispose();
},
onAdImpression: (ad) {
_log.info('Ad impression registered');
},
onAdClicked: (ad) {
_log.info('Ad click registered');
},
),
);
return _bannerAd!.load();
}
Future<void> _showPreloadedAd(PreloadedBannerAd ad) async {
// It's possible that the banner is still loading (even though it started
// preloading at the start of the previous screen).
_adLoadingState = _LoadingState.loading;
try {
_bannerAd = await ad.ready;
} on LoadAdError catch (error) {
_log.severe('Error when loading preloaded banner: $error');
unawaited(_loadAd());
return;
}
if (!mounted) return;
setState(() {
_adLoadingState = _LoadingState.loaded;
});
}
}
enum _LoadingState {
/// The state before we even start loading anything.
initial,
/// The ad is being loaded at this point.
loading,
/// The previous ad is being disposed of. After that is done, the next
/// ad will be loaded.
disposing,
/// An ad has been loaded already.
loaded,
}

View File

@@ -0,0 +1,71 @@
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:async';
import 'dart:io';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'package:logging/logging.dart';
class PreloadedBannerAd {
static final _log = Logger('PreloadedBannerAd');
/// Something like [AdSize.mediumRectangle].
final AdSize size;
final AdRequest _adRequest;
BannerAd? _bannerAd;
final String adUnitId;
final _adCompleter = Completer<BannerAd>();
PreloadedBannerAd({
required this.size,
required this.adUnitId,
AdRequest? adRequest,
}) : _adRequest = adRequest ?? const AdRequest();
Future<BannerAd> get ready => _adCompleter.future;
Future<void> load() {
assert(Platform.isAndroid || Platform.isIOS,
'AdMob currently does not support ${Platform.operatingSystem}');
_bannerAd = BannerAd(
// This is a test ad unit ID from
// https://developers.google.com/admob/android/test-ads. When ready,
// you replace this with your own, production ad unit ID,
// created in https://apps.admob.com/.
adUnitId: adUnitId,
size: size,
request: _adRequest,
listener: BannerAdListener(
onAdLoaded: (ad) {
_log.info(() => 'Ad loaded: ${_bannerAd.hashCode}');
_adCompleter.complete(_bannerAd);
},
onAdFailedToLoad: (ad, error) {
_log.warning('Banner failedToLoad: $error');
_adCompleter.completeError(error);
ad.dispose();
},
onAdImpression: (ad) {
_log.info('Ad impression registered');
},
onAdClicked: (ad) {
_log.info('Ad click registered');
},
),
);
return _bannerAd!.load();
}
void dispose() {
_log.info('preloaded banner ad being disposed');
_bannerAd?.dispose();
}
}

View File

@@ -0,0 +1,63 @@
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'package:flutter/widgets.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
class AppLifecycleObserver extends StatefulWidget {
final Widget child;
const AppLifecycleObserver({required this.child, Key? key}) : super(key: key);
@override
_AppLifecycleObserverState createState() => _AppLifecycleObserverState();
}
class _AppLifecycleObserverState extends State<AppLifecycleObserver>
with WidgetsBindingObserver {
static final _log = Logger('AppLifecycleObserver');
final ValueNotifier<AppLifecycleState> lifecycleListenable =
ValueNotifier(AppLifecycleState.inactive);
@override
Widget build(BuildContext context) {
// Using InheritedProvider because we don't want to use Consumer
// or context.watch or anything like that to listen to this. We want
// to manually add listeners. We're interested in the _events_ of lifecycle
// state changes, and not so much in the state itself. (For example,
// we want to stop sound when the app goes into the background, and
// restart sound again when the app goes back into focus. We're not
// rebuilding any widgets.)
//
// Provider, by default, throws when one
// is trying to provide a Listenable (such as ValueNotifier) without using
// something like ValueListenableProvider. InheritedProvider is more
// low-level and doesn't have this problem.
return InheritedProvider<ValueNotifier<AppLifecycleState>>.value(
value: lifecycleListenable,
child: widget.child,
);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
_log.info(() => 'didChangeAppLifecycleState: $state');
lifecycleListenable.value = state;
}
@override
void dispose() {
WidgetsBinding.instance!.removeObserver(this);
super.dispose();
}
@override
void initState() {
super.initState();
WidgetsBinding.instance!.addObserver(this);
_log.info('Subscribed to app lifecycle updates');
}
}

View File

@@ -0,0 +1,271 @@
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:collection';
import 'dart:math';
import 'package:audioplayers/audioplayers.dart' hide Logger;
import 'package:flutter/widgets.dart';
import 'package:logging/logging.dart';
import '../settings/settings.dart';
import 'songs.dart';
import 'sounds.dart';
/// Allows playing music and sound. A facade to `package:audioplayers`.
class AudioController {
static final _log = Logger('AudioController');
late AudioCache _musicCache;
late AudioCache _sfxCache;
final AudioPlayer _musicPlayer;
/// This is a list of [AudioPlayer] instances which are rotated to play
/// sound effects.
///
/// Normally, we would just call [AudioCache.play] and let it procure its
/// own [AudioPlayer] every time. But this seems to lead to errors and
/// bad performance on iOS devices.
final List<AudioPlayer> _sfxPlayers;
int _currentSfxPlayer = 0;
final Queue<Song> _playlist;
final Random _random = Random();
SettingsController? _settings;
ValueNotifier<AppLifecycleState>? _lifecycleNotifier;
/// Creates an instance that plays music and sound.
///
/// Use [polyphony] to configure the number of sound effects (SFX) that can
/// play at the same time. A [polyphony] of `1` will always only play one
/// sound (a new sound will stop the previous one). See discussion
/// of [_sfxPlayers] to learn why this is the case.
///
/// Background music does not count into the [polyphony] limit. Music will
/// never be overridden by sound effects.
AudioController({int polyphony = 2})
: assert(polyphony >= 1),
_musicPlayer = AudioPlayer(playerId: 'musicPlayer'),
_sfxPlayers = Iterable.generate(
polyphony,
(i) => AudioPlayer(
playerId: 'sfxPlayer#$i',
mode: PlayerMode.LOW_LATENCY)).toList(growable: false),
_playlist = Queue.of(List<Song>.of(songs)..shuffle()) {
_musicCache = AudioCache(
fixedPlayer: _musicPlayer,
prefix: 'assets/music/',
);
_sfxCache = AudioCache(
fixedPlayer: _sfxPlayers.first,
prefix: 'assets/sfx/',
);
_musicPlayer.onPlayerCompletion.listen(_changeSong);
}
/// Enables the [AudioController] to listen to [AppLifecycleState] events,
/// and therefore do things like stopping playback when the game
/// goes into the background.
void attachLifecycleNotifier(
ValueNotifier<AppLifecycleState> lifecycleNotifier) {
_lifecycleNotifier?.removeListener(_handleAppLifecycle);
lifecycleNotifier.addListener(_handleAppLifecycle);
_lifecycleNotifier = lifecycleNotifier;
}
/// Enables the [AudioController] to track changes to settings.
/// Namely, when any of [SettingsController.muted],
/// [SettingsController.musicOn] or [SettingsController.soundsOn] changes,
/// the audio controller will act accordingly.
void attachSettings(SettingsController settingsController) {
if (_settings == settingsController) {
// Already attached to this instance. Nothing to do.
return;
}
// Remove handlers from the old settings controller if present
final oldSettings = _settings;
if (oldSettings != null) {
oldSettings.muted.removeListener(_mutedHandler);
oldSettings.musicOn.removeListener(_musicOnHandler);
oldSettings.soundsOn.removeListener(_soundsOnHandler);
}
_settings = settingsController;
// Add handlers to the new settings controller
settingsController.muted.addListener(_mutedHandler);
settingsController.musicOn.addListener(_musicOnHandler);
settingsController.soundsOn.addListener(_soundsOnHandler);
if (!settingsController.muted.value && settingsController.musicOn.value) {
_startMusic();
}
}
void dispose() {
_lifecycleNotifier?.removeListener(_handleAppLifecycle);
_stopAllSound();
_musicPlayer.dispose();
for (final player in _sfxPlayers) {
player.dispose();
}
}
/// Preloads all sound effects.
Future<void> initialize() async {
_log.info('Preloading sound effects');
// This assumes there is only a limited number of sound effects in the game.
// If there are hundreds of long sound effect files, it's better
// to be more selective when preloading.
await _sfxCache
.loadAll(SfxType.values.expand(soundTypeToFilename).toList());
}
/// Plays a single sound effect, defined by [type].
///
/// The controller will ignore this call when the attached settings'
/// [SettingsController.muted] is `true` or if its
/// [SettingsController.soundsOn] is `false`.
void playSfx(SfxType type) {
final muted = _settings?.muted.value ?? true;
if (muted) {
_log.info(() => 'Ignoring playing sound ($type) because audio is muted.');
return;
}
final soundsOn = _settings?.soundsOn.value ?? false;
if (!soundsOn) {
_log.info(() =>
'Ignoring playing sound ($type) because sounds are turned off.');
return;
}
_log.info(() => 'Playing sound: $type');
final options = soundTypeToFilename(type);
final filename = options[_random.nextInt(options.length)];
_log.info(() => '- Chosen filename: $filename');
_sfxCache.play(filename, volume: soundTypeToVolume(type));
_currentSfxPlayer = (_currentSfxPlayer + 1) % _sfxPlayers.length;
_sfxCache.fixedPlayer = _sfxPlayers[_currentSfxPlayer];
}
void _changeSong(void _) {
_log.info('Last song finished playing.');
// Put the song that just finished playing to the end of the playlist.
_playlist.addLast(_playlist.removeFirst());
// Play the next song.
_log.info(() => 'Playing ${_playlist.first} now.');
_musicCache.play(_playlist.first.filename);
}
void _handleAppLifecycle() {
switch (_lifecycleNotifier!.value) {
case AppLifecycleState.paused:
case AppLifecycleState.detached:
_stopAllSound();
break;
case AppLifecycleState.resumed:
if (!_settings!.muted.value && _settings!.musicOn.value) {
_resumeMusic();
}
break;
case AppLifecycleState.inactive:
// No need to react to this state change.
break;
}
}
void _musicOnHandler() {
if (_settings!.musicOn.value) {
// Music got turned on.
if (!_settings!.muted.value) {
_resumeMusic();
}
} else {
// Music got turned off.
_stopMusic();
}
}
void _mutedHandler() {
if (_settings!.muted.value) {
// All sound just got muted.
_stopAllSound();
} else {
// All sound just got un-muted.
if (_settings!.musicOn.value) {
_resumeMusic();
}
}
}
Future<void> _resumeMusic() async {
_log.info('Resuming music');
switch (_musicPlayer.state) {
case PlayerState.PAUSED:
_log.info('Calling _musicPlayer.resume()');
try {
await _musicPlayer.resume();
} catch (e) {
// Sometimes, resuming fails with an "Unexpected" error.
_log.severe(e);
await _musicCache.play(_playlist.first.filename);
}
break;
case PlayerState.STOPPED:
_log.info("resumeMusic() called when music is stopped. "
"This probably means we haven't yet started the music. "
"For example, the game was started with sound off.");
await _musicCache.play(_playlist.first.filename);
break;
case PlayerState.PLAYING:
_log.warning('resumeMusic() called when music is playing. '
'Nothing to do.');
break;
case PlayerState.COMPLETED:
_log.warning('resumeMusic() called when music is completed. '
"Music should never be 'completed' as it's either not playing "
"or looping forever.");
await _musicCache.play(_playlist.first.filename);
break;
}
}
void _soundsOnHandler() {
for (final player in _sfxPlayers) {
if (player.state == PlayerState.PLAYING) {
player.stop();
}
}
}
void _startMusic() {
_log.info('starting music');
_musicCache.play(_playlist.first.filename);
}
void _stopAllSound() {
if (_musicPlayer.state == PlayerState.PLAYING) {
_musicPlayer.pause();
}
for (final player in _sfxPlayers) {
player.stop();
}
}
void _stopMusic() {
_log.info('Stopping music');
if (_musicPlayer.state == PlayerState.PLAYING) {
_musicPlayer.pause();
}
}
}

View File

@@ -0,0 +1,24 @@
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
const Set<Song> songs = {
// Filenames with whitespace break package:audioplayers on iOS
// (as of February 2022), so we use no whitespace.
Song('Mr_Smith-Azul.mp3', 'Azul', artist: 'Mr Smith'),
Song('Mr_Smith-Sonorus.mp3', 'Sonorus', artist: 'Mr Smith'),
Song('Mr_Smith-Sunday_Solitude.mp3', 'SundaySolitude', artist: 'Mr Smith'),
};
class Song {
final String filename;
final String name;
final String? artist;
const Song(this.filename, this.name, {this.artist});
@override
String toString() => 'Song<$filename>';
}

View File

@@ -0,0 +1,71 @@
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
List<String> soundTypeToFilename(SfxType type) {
switch (type) {
case SfxType.huhsh:
return const [
'hash1.mp3',
'hash2.mp3',
'hash3.mp3',
];
case SfxType.wssh:
return const [
'wssh1.mp3',
'wssh2.mp3',
'dsht1.mp3',
'ws1.mp3',
'spsh1.mp3',
'hh1.mp3',
'hh2.mp3',
'kss1.mp3',
];
case SfxType.buttonTap:
return const [
'k1.mp3',
'k2.mp3',
'p1.mp3',
'p2.mp3',
];
case SfxType.congrats:
return const [
'yay1.mp3',
'wehee1.mp3',
'oo1.mp3',
];
case SfxType.erase:
return const [
'fwfwfwfwfw1.mp3',
'fwfwfwfw1.mp3',
];
case SfxType.swishSwish:
return const [
'swishswish1.mp3',
];
}
}
/// Allows control over loudness of different SFX types.
double soundTypeToVolume(SfxType type) {
switch (type) {
case SfxType.huhsh:
return 0.4;
case SfxType.wssh:
return 0.2;
case SfxType.buttonTap:
case SfxType.congrats:
case SfxType.erase:
case SfxType.swishSwish:
return 1.0;
}
}
enum SfxType {
huhsh,
wssh,
buttonTap,
congrats,
erase,
swishSwish,
}

View File

@@ -0,0 +1,103 @@
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:async';
import 'dart:isolate';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:logging/logging.dart';
/// Runs [mainFunction] in a guarded [Zone].
///
/// If a non-null [FirebaseCrashlytics] instance is provided through
/// [crashlytics], then all errors will be reported through it.
///
/// These errors will also include latest logs from anywhere in the app
/// that use `package:logging`.
Future<void> guardWithCrashlytics(
void Function() mainFunction, {
required FirebaseCrashlytics? crashlytics,
}) async {
// Running the initialization code and [mainFunction] inside a guarded
// zone, so that all errors (even those occurring in callbacks) are
// caught and can be sent to Crashlytics.
await runZonedGuarded<Future<void>>(() async {
if (kDebugMode) {
// Log more when in debug mode.
Logger.root.level = Level.FINE;
}
// Subscribe to log messages.
Logger.root.onRecord.listen((record) {
final message = '${record.level.name}: ${record.time}: '
'${record.loggerName}: '
'${record.message}';
debugPrint(message);
// Add the message to the rotating Crashlytics log.
crashlytics?.log(message);
if (record.level >= Level.SEVERE) {
crashlytics?.recordError(message, filterStackTrace(StackTrace.current));
}
});
// Pass all uncaught errors from the framework to Crashlytics.
if (crashlytics != null) {
WidgetsFlutterBinding.ensureInitialized();
FlutterError.onError = crashlytics.recordFlutterError;
}
if (!kIsWeb) {
// To catch errors outside of the Flutter context, we attach an error
// listener to the current isolate.
Isolate.current.addErrorListener(RawReceivePort((dynamic pair) async {
final errorAndStacktrace = pair as List<dynamic>;
await crashlytics?.recordError(
errorAndStacktrace.first,
errorAndStacktrace.last as StackTrace?,
);
}).sendPort);
}
// Run the actual code.
mainFunction();
}, (error, stack) {
// This sees all errors that occur in the runZonedGuarded zone.
debugPrint('ERROR: $error\n\n'
'STACK:$stack');
crashlytics?.recordError(error, stack);
});
}
/// Takes a [stackTrace] and creates a new one, but without the lines that
/// have to do with this file and logging. This way, Crashlytics won't group
/// all messages that come from this file into one big heap just because
/// the head of the StackTrace is identical.
///
/// See this:
/// https://stackoverflow.com/questions/47654410/how-to-effectively-group-non-fatal-exceptions-in-crashlytics-fabrics.
@visibleForTesting
StackTrace filterStackTrace(StackTrace stackTrace) {
try {
final lines = stackTrace.toString().split('\n');
final buf = StringBuffer();
for (final line in lines) {
if (line.contains('crashlytics.dart') ||
line.contains('_BroadcastStreamController.java') ||
line.contains('logger.dart')) {
continue;
}
buf.writeln(line);
}
return StackTrace.fromString(buf.toString());
} catch (e) {
debugPrint('Problem while filtering stack trace: $e');
}
// If there was an error while filtering,
// return the original, unfiltered stack track.
return stackTrace;
}

View File

@@ -0,0 +1,32 @@
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'package:flutter/foundation.dart';
/// An extremely silly example of a game state.
///
/// Tracks only a single variable, [progress], and calls [onWin] when
/// the value of [progress] reaches [goal].
class LevelState extends ChangeNotifier {
final VoidCallback onWin;
final int goal;
LevelState({required this.onWin, this.goal = 100});
int _progress = 0;
int get progress => _progress;
void setProgress(int value) {
_progress = value;
notifyListeners();
}
void evaluate() {
if (_progress >= goal) {
onWin();
}
}
}

View File

@@ -0,0 +1,119 @@
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:async';
import 'package:games_services/games_services.dart' as gs;
import 'package:logging/logging.dart';
import 'score.dart';
/// Allows awarding achievements and leaderboard scores,
/// and also showing the platforms' UI overlays for achievements
/// and leaderboards.
///
/// A facade of `package:games_services`.
class GamesServicesController {
static final Logger _log = Logger('GamesServicesController');
final Completer<bool> _signedInCompleter = Completer();
Future<bool> get signedIn => _signedInCompleter.future;
/// Unlocks an achievement on Game Center / Play Games.
///
/// You must provide the achievement ids via the [iOS] and [android]
/// parameters.
///
/// Does nothing when the game isn't signed into the underlying
/// games service.
Future<void> awardAchievement(
{required String iOS, required String android}) async {
if (!await signedIn) {
_log.warning('Trying to award achievement when not logged in.');
return;
}
try {
await gs.GamesServices.unlock(
achievement: gs.Achievement(
androidID: android,
iOSID: iOS,
),
);
} catch (e) {
_log.severe('Cannot award achievement: $e');
}
}
/// Signs into the underlying games service.
Future<void> initialize() async {
try {
await gs.GamesServices.signIn();
// The API is unclear so we're checking to be sure. The above call
// returns a String, not a boolean, and there's no documentation
// as to whether every non-error result means we're safely signed in.
final signedIn = await gs.GamesServices.isSignedIn;
_signedInCompleter.complete(signedIn);
} catch (e) {
_log.severe('Cannot log into GamesServices: $e');
_signedInCompleter.complete(false);
}
}
/// Launches the platform's UI overlay with achievements.
Future<void> showAchievements() async {
if (!await signedIn) {
_log.severe('Trying to show achievements when not logged in.');
return;
}
try {
await gs.GamesServices.showAchievements();
} catch (e) {
_log.severe('Cannot show achievements: $e');
}
}
/// Launches the platform's UI overlay with leaderboard(s).
Future<void> showLeaderboard() async {
if (!await signedIn) {
_log.severe('Trying to show leaderboard when not logged in.');
return;
}
try {
await gs.GamesServices.showLeaderboards(
// TODO: When ready, change both these leaderboard IDs.
iOSLeaderboardID: "some_id_from_app_store",
androidLeaderboardID: "sOmE_iD_fRoM_gPlAy",
);
} catch (e) {
_log.severe('Cannot show leaderboard: $e');
}
}
/// Submits [score] to the leaderboard.
Future<void> submitLeaderboardScore(Score score) async {
if (!await signedIn) {
_log.warning('Trying to submit leaderboard when not logged in.');
return;
}
_log.info('Submitting $score to leaderboard.');
try {
await gs.GamesServices.submitScore(
score: gs.Score(
// TODO: When ready, change these leaderboard IDs.
iOSLeaderboardID: 'some_id_from_app_store',
androidLeaderboardID: 'sOmE_iD_fRoM_gPlAy',
value: score.score,
),
);
} catch (e) {
_log.severe('Cannot submit leaderboard score: $e');
}
}
}

View File

@@ -0,0 +1,48 @@
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'package:flutter/foundation.dart';
/// Encapsulates a score and the arithmetic to compute it.
@immutable
class Score {
final int score;
final Duration duration;
final int level;
factory Score(int level, int difficulty, Duration duration) {
// The higher the difficulty, the higher the score.
var score = difficulty;
// The lower the time to beat the level, the higher the score.
score *= 10000 ~/ (duration.inSeconds.abs() + 1);
return Score._(score, duration, level);
}
const Score._(this.score, this.duration, this.level);
String get formattedTime {
final buf = StringBuffer();
if (duration.inHours > 0) {
buf.write('${duration.inHours}');
buf.write(':');
}
final minutes = duration.inMinutes % Duration.minutesPerHour;
if (minutes > 9) {
buf.write('$minutes');
} else {
buf.write('0');
buf.write('$minutes');
}
buf.write(':');
buf.write((duration.inSeconds % Duration.secondsPerMinute)
.toString()
.padLeft(2, '0'));
return buf.toString();
}
@override
String toString() => 'Score<$score,$formattedTime,$level>';
}

View File

@@ -0,0 +1,41 @@
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
/// Represents the state of an in-app purchase of ad removal such as
/// [AdRemovalPurchase.notStarted()] or [AdRemovalPurchase.active()].
class AdRemovalPurchase {
/// The representation of this product on the stores.
static const productId = 'remove_ads';
/// This is `true` if the `remove_ad` product has been purchased and verified.
/// Do not show ads if so.
final bool active;
/// This is `true` when the purchase is pending.
final bool pending;
/// If there was an error with the purchase, this field will contain
/// that error.
final Object? error;
const AdRemovalPurchase.active() : this._(true, false, null);
const AdRemovalPurchase.error(Object error) : this._(false, false, error);
const AdRemovalPurchase.notStarted() : this._(false, false, null);
const AdRemovalPurchase.pending() : this._(false, true, null);
const AdRemovalPurchase._(this.active, this.pending, this.error);
@override
int get hashCode => Object.hash(active, pending, error);
@override
bool operator ==(Object other) =>
other is AdRemovalPurchase &&
other.active == active &&
other.pending == pending &&
other.error == error;
}

View File

@@ -0,0 +1,193 @@
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
import 'package:logging/logging.dart';
import '../style/snack_bar.dart';
import 'ad_removal.dart';
/// Allows buying in-app. Facade of `package:in_app_purchase`.
class InAppPurchaseController extends ChangeNotifier {
static final Logger _log = Logger('InAppPurchases');
StreamSubscription<List<PurchaseDetails>>? _subscription;
InAppPurchase inAppPurchaseInstance;
AdRemovalPurchase _adRemoval = const AdRemovalPurchase.notStarted();
/// Creates a new [InAppPurchaseController] with an injected
/// [InAppPurchase] instance.
///
/// Example usage:
///
/// var controller = InAppPurchaseController(InAppPurchase.instance);
InAppPurchaseController(this.inAppPurchaseInstance);
/// The current state of the ad removal purchase.
AdRemovalPurchase get adRemoval => _adRemoval;
/// Launches the platform UI for buying an in-app purchase.
///
/// Currently, the only supported in-app purchase is ad removal.
/// To support more, ad additional classes similar to [AdRemovalPurchase]
/// and modify this method.
Future<void> buy() async {
if (!await inAppPurchaseInstance.isAvailable()) {
_reportError('InAppPurchase.instance not available');
return;
}
_adRemoval = const AdRemovalPurchase.pending();
notifyListeners();
_log.info('Querying the store with queryProductDetails()');
final response = await inAppPurchaseInstance
.queryProductDetails({AdRemovalPurchase.productId});
if (response.error != null) {
_reportError('There was an error when making the purchase: '
'${response.error}');
return;
}
if (response.productDetails.length != 1) {
_log.info(
'Products in response: '
'${response.productDetails.map((e) => '${e.id}: ${e.title}, ').join()}',
);
_reportError('There was an error when making the purchase: '
'product ${AdRemovalPurchase.productId} does not exist?');
return;
}
final productDetails = response.productDetails.single;
_log.info('Making the purchase');
final purchaseParam = PurchaseParam(productDetails: productDetails);
try {
final success = await inAppPurchaseInstance.buyNonConsumable(
purchaseParam: purchaseParam);
_log.info('buyNonConsumable() request was sent with success: $success');
// The result of the purchase will be reported in the purchaseStream,
// which is handled in [_listenToPurchaseUpdated].
} catch (e) {
_log.severe(
'Problem with calling inAppPurchaseInstance.buyNonConsumable(): '
'$e');
}
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
/// Asks the underlying platform to list purchases that have been already
/// made (for example, in a previous session of the game).
Future<void> restorePurchases() async {
if (!await inAppPurchaseInstance.isAvailable()) {
_reportError('InAppPurchase.instance not available');
return;
}
try {
await inAppPurchaseInstance.restorePurchases();
} catch (e) {
_log.severe('Could not restore in-app purchases: $e');
}
_log.info('In-app purchases restored');
}
/// Subscribes to the [inAppPurchaseInstance.purchaseStream].
void subscribe() {
_subscription?.cancel();
_subscription =
inAppPurchaseInstance.purchaseStream.listen((purchaseDetailsList) {
_listenToPurchaseUpdated(purchaseDetailsList);
}, onDone: () {
_subscription?.cancel();
}, onError: (dynamic error) {
_log.severe('Error occurred on the purchaseStream: $error');
});
}
Future<void> _listenToPurchaseUpdated(
List<PurchaseDetails> purchaseDetailsList) async {
for (final purchaseDetails in purchaseDetailsList) {
_log.info(() => 'New PurchaseDetails instance received: '
'productID=${purchaseDetails.productID}, '
'status=${purchaseDetails.status}, '
'purchaseID=${purchaseDetails.purchaseID}, '
'error=${purchaseDetails.error}, '
'pendingCompletePurchase=${purchaseDetails.pendingCompletePurchase}');
if (purchaseDetails.productID != AdRemovalPurchase.productId) {
_log.severe("The handling of the product with id "
"'${purchaseDetails.productID}' is not implemented.");
_adRemoval = const AdRemovalPurchase.notStarted();
notifyListeners();
continue;
}
switch (purchaseDetails.status) {
case PurchaseStatus.pending:
_adRemoval = const AdRemovalPurchase.pending();
notifyListeners();
break;
case PurchaseStatus.purchased:
case PurchaseStatus.restored:
bool valid = await _verifyPurchase(purchaseDetails);
if (valid) {
_adRemoval = const AdRemovalPurchase.active();
if (purchaseDetails.status == PurchaseStatus.purchased) {
showSnackBar('Thank you for your support!');
}
notifyListeners();
} else {
_log.severe('Purchase verification failed: $purchaseDetails');
_adRemoval = AdRemovalPurchase.error(
StateError('Purchase could not be verified'));
notifyListeners();
}
break;
case PurchaseStatus.error:
_log.severe('Error with purchase: ${purchaseDetails.error}');
_adRemoval = AdRemovalPurchase.error(purchaseDetails.error!);
notifyListeners();
break;
case PurchaseStatus.canceled:
_adRemoval = const AdRemovalPurchase.notStarted();
notifyListeners();
break;
}
if (purchaseDetails.pendingCompletePurchase) {
// Confirm purchase back to the store.
await inAppPurchaseInstance.completePurchase(purchaseDetails);
}
}
}
void _reportError(String message) {
_log.severe(message);
showSnackBar(message);
_adRemoval = AdRemovalPurchase.error(message);
notifyListeners();
}
Future<bool> _verifyPurchase(PurchaseDetails purchaseDetails) async {
_log.info('Verifying purchase: ${purchaseDetails.verificationData}');
// TODO: verify the purchase.
// See the info in [purchaseDetails.verificationData] to learn more.
// There's also a codelab that explains purchase verification
// on the backend:
// https://codelabs.developers.google.com/codelabs/flutter-in-app-purchases#9
return true;
}
}

View File

@@ -0,0 +1,71 @@
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import '../audio/audio_controller.dart';
import '../audio/sounds.dart';
import '../player_progress/player_progress.dart';
import '../style/palette.dart';
import '../style/responsive_screen.dart';
import 'levels.dart';
class LevelSelectionScreen extends StatelessWidget {
const LevelSelectionScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final palette = context.watch<Palette>();
final playerProgress = context.watch<PlayerProgress>();
return Scaffold(
backgroundColor: palette.backgroundLevelSelection,
body: ResponsiveScreen(
squarishMainArea: Column(
children: [
const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: Text(
'Select level',
style:
TextStyle(fontFamily: 'Permanent Marker', fontSize: 30),
),
),
),
const SizedBox(height: 50),
Expanded(
child: ListView(
children: [
for (final level in gameLevels)
ListTile(
enabled: playerProgress.highestLevelReached >=
level.number - 1,
onTap: () {
final audioController = context.read<AudioController>();
audioController.playSfx(SfxType.buttonTap);
GoRouter.of(context)
.go('/play/session/${level.number}');
},
leading: Text(level.number.toString()),
title: Text('Level #${level.number}'),
)
],
),
),
],
),
rectangularMenuArea: ElevatedButton(
onPressed: () {
GoRouter.of(context).pop();
},
child: const Text('Back'),
),
),
);
}
}

View File

@@ -0,0 +1,49 @@
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
const gameLevels = [
GameLevel(
number: 1,
difficulty: 5,
// TODO: When ready, change these achievement IDs.
// You configure this in App Store Connect.
achievementIdIOS: 'first_win',
// You get this string when you configure an achievement in Play Console.
achievementIdAndroid: 'NhkIwB69ejkMAOOLDb',
),
GameLevel(
number: 2,
difficulty: 42,
),
GameLevel(
number: 3,
difficulty: 100,
achievementIdIOS: 'finished',
achievementIdAndroid: 'CdfIhE96aspNWLGSQg',
),
];
class GameLevel {
final int number;
final int difficulty;
/// The achievement to unlock when the level is finished, if any.
final String? achievementIdIOS;
final String? achievementIdAndroid;
bool get awardsAchievement => achievementIdAndroid != null;
const GameLevel({
required this.number,
required this.difficulty,
this.achievementIdIOS,
this.achievementIdAndroid,
}) : assert(
(achievementIdAndroid != null && achievementIdIOS != null) ||
(achievementIdAndroid == null && achievementIdIOS == null),
'Either both iOS and Android achievement ID must be provided, '
'or none');
}

View File

@@ -0,0 +1,123 @@
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import '../audio/audio_controller.dart';
import '../audio/sounds.dart';
import '../games_services/games_services.dart';
import '../settings/settings.dart';
import '../style/palette.dart';
import '../style/responsive_screen.dart';
class MainMenuScreen extends StatelessWidget {
const MainMenuScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final palette = context.watch<Palette>();
final gamesServicesController = context.watch<GamesServicesController?>();
final settingsController = context.watch<SettingsController>();
final audioController = context.watch<AudioController>();
return Scaffold(
backgroundColor: palette.backgroundMain,
body: ResponsiveScreen(
mainAreaProminence: 0.45,
squarishMainArea: Center(
child: Transform.rotate(
angle: -0.1,
child: const Text(
'Flutter Game Template!',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'Permanent Marker',
fontSize: 55,
height: 1,
),
),
),
),
rectangularMenuArea: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ElevatedButton(
onPressed: () {
audioController.playSfx(SfxType.buttonTap);
GoRouter.of(context).go('/play');
},
child: const Text('Play'),
),
_gap,
if (gamesServicesController != null) ...[
_hideUntilReady(
ready: gamesServicesController.signedIn,
child: ElevatedButton(
onPressed: () => gamesServicesController.showAchievements(),
child: const Text('Achievements'),
),
),
_gap,
_hideUntilReady(
ready: gamesServicesController.signedIn,
child: ElevatedButton(
onPressed: () => gamesServicesController.showLeaderboard(),
child: const Text('Leaderboard'),
),
),
_gap,
],
ElevatedButton(
onPressed: () => GoRouter.of(context).go('/settings'),
child: const Text('Settings'),
),
_gap,
Padding(
padding: const EdgeInsets.only(top: 32),
child: ValueListenableBuilder<bool>(
valueListenable: settingsController.muted,
builder: (context, muted, child) {
return IconButton(
onPressed: () => settingsController.toggleMuted(),
icon: Icon(muted ? Icons.volume_off : Icons.volume_up),
);
},
),
),
_gap,
const Text('Music by Mr Smith'),
_gap,
],
),
),
);
}
/// Prevents the game from showing game-services-related menu items
/// until we're sure the player is signed in.
///
/// This normally happens immediately after game start, so players will not
/// see any flash. The exception is folks who decline to use Game Center
/// or Google Play Game Services, or who haven't yet set it up.
Widget _hideUntilReady({required Widget child, required Future<bool> ready}) {
return FutureBuilder<bool>(
future: ready,
builder: (context, snapshot) {
// Use Visibility here so that we have the space for the buttons
// ready.
return Visibility(
visible: snapshot.data ?? false,
maintainState: true,
maintainSize: true,
maintainAnimation: true,
child: child,
);
},
);
}
static const _gap = SizedBox(height: 10);
}

View File

@@ -0,0 +1,180 @@
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:logging/logging.dart' hide Level;
import 'package:provider/provider.dart';
import '../ads/ads_controller.dart';
import '../audio/audio_controller.dart';
import '../audio/sounds.dart';
import '../game_internals/level_state.dart';
import '../games_services/games_services.dart';
import '../games_services/score.dart';
import '../in_app_purchase/in_app_purchase.dart';
import '../level_selection/levels.dart';
import '../player_progress/player_progress.dart';
import '../style/confetti.dart';
import '../style/palette.dart';
class PlaySessionScreen extends StatefulWidget {
final GameLevel level;
const PlaySessionScreen(this.level, {Key? key}) : super(key: key);
@override
State<PlaySessionScreen> createState() => _PlaySessionScreenState();
}
class _PlaySessionScreenState extends State<PlaySessionScreen> {
static final _log = Logger('PlaySessionScreen');
static const _celebrationDuration = Duration(milliseconds: 2000);
static const _preCelebrationDuration = Duration(milliseconds: 500);
bool _duringCelebration = false;
late DateTime _startOfPlay;
@override
Widget build(BuildContext context) {
final palette = context.watch<Palette>();
return MultiProvider(
providers: [
ChangeNotifierProvider(
create: (context) => LevelState(
goal: widget.level.difficulty,
onWin: _playerWon,
),
),
],
child: IgnorePointer(
ignoring: _duringCelebration,
child: Scaffold(
backgroundColor: palette.backgroundPlaySession,
body: Stack(
children: [
Center(
// This is the entirety of the "game".
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Align(
alignment: Alignment.centerRight,
child: InkResponse(
onTap: () => GoRouter.of(context).push('/settings'),
child: Image.asset(
'assets/images/settings.png',
semanticLabel: 'Settings',
),
),
),
const Spacer(),
Text('Drag the slider to ${widget.level.difficulty}%'
' or above!'),
Consumer<LevelState>(
builder: (context, levelState, child) => Slider(
label: 'Level Progress',
autofocus: true,
value: levelState.progress / 100,
onChanged: (value) =>
levelState.setProgress((value * 100).round()),
onChangeEnd: (value) => levelState.evaluate(),
),
),
const Spacer(),
Padding(
padding: const EdgeInsets.all(8.0),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => GoRouter.of(context).pop(),
child: const Text('Back'),
),
),
),
],
),
),
SizedBox.expand(
child: Visibility(
visible: _duringCelebration,
child: IgnorePointer(
child: Confetti(
isStopped: !_duringCelebration,
),
),
),
),
],
),
),
),
);
}
@override
void initState() {
super.initState();
_startOfPlay = DateTime.now();
// Preload ad for the win screen.
final adsRemoved =
context.read<InAppPurchaseController?>()?.adRemoval.active ?? false;
if (!adsRemoved) {
final adsController = context.read<AdsController?>();
adsController?.preloadAd();
}
}
Future<void> _playerWon() async {
_log.info('Level ${widget.level.number} won');
final score = Score(
widget.level.number,
widget.level.difficulty,
DateTime.now().difference(_startOfPlay),
);
final playerProgress = context.read<PlayerProgress>();
playerProgress.setLevelReached(widget.level.number);
// Let the player see the game just after winning for a bit.
await Future<void>.delayed(_preCelebrationDuration);
if (!mounted) return;
setState(() {
_duringCelebration = true;
});
final audioController = context.read<AudioController>();
audioController.playSfx(SfxType.congrats);
final gamesServicesController = context.read<GamesServicesController?>();
if (gamesServicesController != null) {
// Award achievement.
if (widget.level.awardsAchievement) {
await gamesServicesController.awardAchievement(
android: widget.level.achievementIdAndroid!,
iOS: widget.level.achievementIdIOS!,
);
}
// Send score to leaderboard.
await gamesServicesController.submitLeaderboardScore(score);
}
/// Give the player some time to see the celebration animation.
await Future<void>.delayed(_celebrationDuration);
if (!mounted) return;
GoRouter.of(context).go('/play/won', extra: {'score': score});
}
}

View File

@@ -0,0 +1,26 @@
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'package:shared_preferences/shared_preferences.dart';
import 'player_progress_persistence.dart';
/// An implementation of [PlayerProgressPersistence] that uses
/// `package:shared_preferences`.
class LocalStoragePlayerProgressPersistence extends PlayerProgressPersistence {
final Future<SharedPreferences> instanceFuture =
SharedPreferences.getInstance();
@override
Future<int> getHighestLevelReached() async {
final prefs = await instanceFuture;
return prefs.getInt('highestLevelReached') ?? 0;
}
@override
Future<void> saveHighestLevelReached(int level) async {
final prefs = await instanceFuture;
await prefs.setInt('highestLevelReached', level);
}
}

View File

@@ -0,0 +1,23 @@
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'player_progress_persistence.dart';
/// An in-memory implementation of [PlayerProgressPersistence].
/// Useful for testing.
class MemoryOnlyPlayerProgressPersistence implements PlayerProgressPersistence {
int level = 0;
@override
Future<int> getHighestLevelReached() async {
await Future<void>.delayed(const Duration(milliseconds: 500));
return level;
}
@override
Future<void> saveHighestLevelReached(int level) async {
await Future<void>.delayed(const Duration(milliseconds: 500));
this.level = level;
}
}

View File

@@ -0,0 +1,13 @@
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
/// An interface of persistence stores for the player's progress.
///
/// Implementations can range from simple in-memory storage through
/// local preferences to cloud saves.
abstract class PlayerProgressPersistence {
Future<int> getHighestLevelReached();
Future<void> saveHighestLevelReached(int level);
}

View File

@@ -0,0 +1,57 @@
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'persistence/player_progress_persistence.dart';
/// Encapsulates the player's progress.
class PlayerProgress extends ChangeNotifier {
static const maxHighestScoresPerPlayer = 10;
final PlayerProgressPersistence _store;
int _highestLevelReached = 0;
/// Creates an instance of [PlayerProgress] backed by an injected
/// persistence [store].
PlayerProgress(PlayerProgressPersistence store) : _store = store;
/// The highest level that the player has reached so far.
int get highestLevelReached => _highestLevelReached;
/// Fetches the latest data from the backing persistence store.
Future<void> getLatestFromStore() async {
final level = await _store.getHighestLevelReached();
if (level > _highestLevelReached) {
_highestLevelReached = level;
notifyListeners();
} else if (level < _highestLevelReached) {
await _store.saveHighestLevelReached(_highestLevelReached);
}
}
/// Resets the player's progress so it's like if they just started
/// playing the game for the first time.
void reset() {
_highestLevelReached = 0;
notifyListeners();
_store.saveHighestLevelReached(_highestLevelReached);
}
/// Registers [level] as reached.
///
/// If this is higher than [highestLevelReached], it will update that
/// value and save it to the injected persistence store.
void setLevelReached(int level) {
if (level > _highestLevelReached) {
_highestLevelReached = level;
notifyListeners();
unawaited(_store.saveHighestLevelReached(level));
}
}
}

View File

@@ -0,0 +1,75 @@
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:game_template/src/settings/settings.dart';
import 'package:provider/provider.dart';
void showCustomNameDialog(BuildContext context) {
showGeneralDialog(
context: context,
pageBuilder: (context, animation, secondaryAnimation) =>
CustomNameDialog(animation: animation));
}
class CustomNameDialog extends StatefulWidget {
final Animation<double> animation;
const CustomNameDialog({required this.animation, Key? key}) : super(key: key);
@override
State<CustomNameDialog> createState() => _CustomNameDialogState();
}
class _CustomNameDialogState extends State<CustomNameDialog> {
final TextEditingController _controller = TextEditingController();
@override
Widget build(BuildContext context) {
return ScaleTransition(
scale: CurvedAnimation(
parent: widget.animation,
curve: Curves.easeOutCubic,
),
child: SimpleDialog(
title: const Text('Change name'),
children: [
TextField(
controller: _controller,
autofocus: true,
maxLength: 12,
maxLengthEnforcement: MaxLengthEnforcement.enforced,
textAlign: TextAlign.center,
textCapitalization: TextCapitalization.words,
textInputAction: TextInputAction.done,
onChanged: (value) {
context.read<SettingsController>().setPlayerName(value);
},
onSubmitted: (value) {
// Player tapped 'Submit'/'Done' on their keyboard.
Navigator.pop(context);
},
),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
);
}
@override
void didChangeDependencies() {
_controller.text = context.read<SettingsController>().playerName.value;
super.didChangeDependencies();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}

View File

@@ -0,0 +1,62 @@
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'package:shared_preferences/shared_preferences.dart';
import 'settings_persistence.dart';
/// An implementation of [SettingsPersistence] that uses
/// `package:shared_preferences`.
class LocalStorageSettingsPersistence extends SettingsPersistence {
final Future<SharedPreferences> instanceFuture =
SharedPreferences.getInstance();
@override
Future<bool> getMusicOn() async {
final prefs = await instanceFuture;
return prefs.getBool('musicOn') ?? true;
}
@override
Future<bool> getMuted({required bool defaultValue}) async {
final prefs = await instanceFuture;
return prefs.getBool('mute') ?? defaultValue;
}
@override
Future<String> getPlayerName() async {
final prefs = await instanceFuture;
return prefs.getString('playerName') ?? 'Player';
}
@override
Future<bool> getSoundsOn() async {
final prefs = await instanceFuture;
return prefs.getBool('soundsOn') ?? true;
}
@override
Future<void> saveMusicOn(bool value) async {
final prefs = await instanceFuture;
await prefs.setBool('musicOn', value);
}
@override
Future<void> saveMuted(bool value) async {
final prefs = await instanceFuture;
await prefs.setBool('mute', value);
}
@override
Future<void> savePlayerName(String value) async {
final prefs = await instanceFuture;
await prefs.setString('playerName', value);
}
@override
Future<void> saveSoundsOn(bool value) async {
final prefs = await instanceFuture;
await prefs.setBool('soundsOn', value);
}
}

View File

@@ -0,0 +1,41 @@
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'package:game_template/src/settings/persistence/settings_persistence.dart';
/// An in-memory implementation of [SettingsPersistence].
/// Useful for testing.
class MemoryOnlySettingsPersistence implements SettingsPersistence {
bool musicOn = true;
bool soundsOn = true;
bool muted = false;
String playerName = 'Player';
@override
Future<bool> getMusicOn() async => musicOn;
@override
Future<bool> getMuted({required bool defaultValue}) async => muted;
@override
Future<String> getPlayerName() async => playerName;
@override
Future<bool> getSoundsOn() async => soundsOn;
@override
Future<void> saveMusicOn(bool value) async => musicOn = value;
@override
Future<void> saveMuted(bool value) async => muted = value;
@override
Future<void> savePlayerName(String value) async => playerName = value;
@override
Future<void> saveSoundsOn(bool value) async => soundsOn = value;
}

View File

@@ -0,0 +1,25 @@
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
/// An interface of persistence stores for settings.
///
/// Implementations can range from simple in-memory storage through
/// local preferences to cloud-based solutions.
abstract class SettingsPersistence {
Future<bool> getMusicOn();
Future<bool> getMuted({required bool defaultValue});
Future<String> getPlayerName();
Future<bool> getSoundsOn();
Future<void> saveMusicOn(bool value);
Future<void> saveMuted(bool value);
Future<void> savePlayerName(String value);
Future<void> saveSoundsOn(bool value);
}

View File

@@ -0,0 +1,62 @@
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'package:flutter/foundation.dart';
import 'persistence/settings_persistence.dart';
/// An class that holds settings like [playerName] or [musicOn],
/// and saves them to an injected persistence store.
class SettingsController {
final SettingsPersistence _persistence;
/// Whether or not the sound is on at all. This overrides both music
/// and sound.
ValueNotifier<bool> muted = ValueNotifier(false);
ValueNotifier<String> playerName = ValueNotifier('Player');
ValueNotifier<bool> soundsOn = ValueNotifier(false);
ValueNotifier<bool> musicOn = ValueNotifier(false);
/// Creates a new instance of [SettingsController] backed by [persistence].
SettingsController({required SettingsPersistence persistence})
: _persistence = persistence;
/// Asynchronously loads values from the injected persistence store.
Future<void> loadStateFromPersistence() async {
await Future.wait([
_persistence
// On the web, sound can only start after user interaction, so
// we start muted there.
// On any other platform, we start unmuted.
.getMuted(defaultValue: kIsWeb)
.then((value) => muted.value = value),
_persistence.getSoundsOn().then((value) => soundsOn.value = value),
_persistence.getMusicOn().then((value) => musicOn.value = value),
_persistence.getPlayerName().then((value) => playerName.value = value),
]);
}
void setPlayerName(String name) {
playerName.value = name;
_persistence.savePlayerName(playerName.value);
}
void toggleMusicOn() {
musicOn.value = !musicOn.value;
_persistence.saveMusicOn(musicOn.value);
}
void toggleMuted() {
muted.value = !muted.value;
_persistence.saveMuted(muted.value);
}
void toggleSoundsOn() {
soundsOn.value = !soundsOn.value;
_persistence.saveSoundsOn(soundsOn.value);
}
}

View File

@@ -0,0 +1,187 @@
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import '../in_app_purchase/in_app_purchase.dart';
import '../player_progress/player_progress.dart';
import '../style/palette.dart';
import '../style/responsive_screen.dart';
import 'custom_name_dialog.dart';
import 'settings.dart';
class SettingsScreen extends StatelessWidget {
const SettingsScreen({Key? key}) : super(key: key);
static const _gap = SizedBox(height: 60);
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsController>();
final palette = context.watch<Palette>();
return Scaffold(
backgroundColor: palette.backgroundSettings,
body: ResponsiveScreen(
squarishMainArea: ListView(
children: [
_gap,
const Text(
'Settings',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'Permanent Marker',
fontSize: 55,
height: 1,
),
),
_gap,
const _NameChangeLine(
'Name',
),
ValueListenableBuilder<bool>(
valueListenable: settings.soundsOn,
builder: (context, soundsOn, child) => _SettingsLine(
'Sound FX',
Icon(soundsOn ? Icons.graphic_eq : Icons.volume_off),
onSelected: () => settings.toggleSoundsOn(),
),
),
ValueListenableBuilder<bool>(
valueListenable: settings.musicOn,
builder: (context, musicOn, child) => _SettingsLine(
'Music',
Icon(musicOn ? Icons.music_note : Icons.music_off),
onSelected: () => settings.toggleMusicOn(),
),
),
Consumer<InAppPurchaseController?>(
builder: (context, inAppPurchase, child) {
if (inAppPurchase == null) {
// In-app purchases are not supported yet.
// Go to lib/main.dart and uncomment the lines that create
// the InAppPurchaseController.
return const SizedBox.shrink();
}
Widget icon;
VoidCallback? callback;
if (inAppPurchase.adRemoval.active) {
icon = const Icon(Icons.check);
} else if (inAppPurchase.adRemoval.pending) {
icon = const CircularProgressIndicator();
} else {
icon = const Icon(Icons.ad_units);
callback = () {
inAppPurchase.buy();
};
}
return _SettingsLine(
'Remove ads',
icon,
onSelected: callback,
);
}),
_SettingsLine(
'Reset progress',
const Icon(Icons.delete),
onSelected: () {
context.read<PlayerProgress>().reset();
final messenger = ScaffoldMessenger.of(context);
messenger.showSnackBar(
const SnackBar(
content: Text('Player progress has been reset.')),
);
},
),
_gap,
],
),
rectangularMenuArea: ElevatedButton(
onPressed: () {
GoRouter.of(context).pop();
},
child: const Text('Back'),
),
),
);
}
}
class _NameChangeLine extends StatelessWidget {
final String title;
const _NameChangeLine(this.title, {Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsController>();
return InkResponse(
highlightShape: BoxShape.rectangle,
onTap: () => showCustomNameDialog(context),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(title,
style: const TextStyle(
fontFamily: 'Permanent Marker',
fontSize: 30,
)),
const Spacer(),
ValueListenableBuilder(
valueListenable: settings.playerName,
builder: (context, name, child) => Text(
'$name',
style: const TextStyle(
fontFamily: 'Permanent Marker',
fontSize: 30,
),
),
),
],
),
),
);
}
}
class _SettingsLine extends StatelessWidget {
final String title;
final Widget icon;
final VoidCallback? onSelected;
const _SettingsLine(this.title, this.icon, {this.onSelected, Key? key})
: super(key: key);
@override
Widget build(BuildContext context) {
return InkResponse(
highlightShape: BoxShape.rectangle,
onTap: onSelected,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(title,
style: const TextStyle(
fontFamily: 'Permanent Marker',
fontSize: 30,
)),
const Spacer(),
icon,
],
),
),
);
}
}

View File

@@ -0,0 +1,234 @@
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:collection';
import 'dart:math';
import 'package:flutter/widgets.dart';
/// Shows a confetti (celebratory) animation: paper snippings falling down.
///
/// The widget fills the available space (like [SizedBox.expand] would).
///
/// When [isStopped] is `true`, the animation will not run. This is useful
/// when the widget is not visible yet, for example. Provide [colors]
/// to make the animation look good in context.
///
/// This is a partial port of this CodePen by Hemn Chawroka:
/// https://codepen.io/iprodev/pen/azpWBr
class Confetti extends StatefulWidget {
static const _defaultColors = [
Color(0xffd10841),
Color(0xff1d75fb),
Color(0xff0050bc),
Color(0xffa2dcc7),
];
final bool isStopped;
final List<Color> colors;
const Confetti({
this.colors = _defaultColors,
this.isStopped = false,
Key? key,
}) : super(key: key);
@override
State<Confetti> createState() => _ConfettiState();
}
class ConfettiPainter extends CustomPainter {
final defaultPaint = Paint();
final int snippingsCount = 200;
late final List<_PaperSnipping> _snippings;
Size? _size;
DateTime _lastTime = DateTime.now();
final UnmodifiableListView<Color> colors;
ConfettiPainter(
{required Listenable animation, required Iterable<Color> colors})
: colors = UnmodifiableListView(colors),
super(repaint: animation);
@override
void paint(Canvas canvas, Size size) {
if (_size == null) {
// First time we have a size.
_snippings = List.generate(
snippingsCount,
(i) => _PaperSnipping(
frontColor: colors[i % colors.length],
bounds: size,
));
}
final didResize = _size != null && _size != size;
final now = DateTime.now();
final dt = now.difference(_lastTime);
for (final snipping in _snippings) {
if (didResize) {
snipping.updateBounds(size);
}
snipping.update(dt.inMilliseconds / 1000);
snipping.draw(canvas);
}
_size = size;
_lastTime = now;
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return true;
}
}
class _ConfettiState extends State<Confetti>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: ConfettiPainter(
colors: widget.colors,
animation: _controller,
),
willChange: true,
child: const SizedBox.expand(),
);
}
@override
void didUpdateWidget(covariant Confetti oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.isStopped && !widget.isStopped) {
_controller.repeat();
} else if (!oldWidget.isStopped && widget.isStopped) {
_controller.stop(canceled: false);
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
void initState() {
super.initState();
_controller = AnimationController(
// We don't really care about the duration, since we're going to
// use the controller on loop anyway.
duration: const Duration(seconds: 1),
vsync: this,
);
if (!widget.isStopped) {
_controller.repeat();
}
}
}
class _PaperSnipping {
static final Random _random = Random();
static const degToRad = pi / 180;
static const backSideBlend = Color(0x70EEEEEE);
Size _bounds;
late final _Vector position = _Vector(
_random.nextDouble() * _bounds.width,
_random.nextDouble() * _bounds.height,
);
final double rotationSpeed = 800 + _random.nextDouble() * 600;
final double angle = _random.nextDouble() * 360 * degToRad;
double rotation = _random.nextDouble() * 360 * degToRad;
double cosA = 1.0;
final double size = 7.0;
final double oscillationSpeed = 0.5 + _random.nextDouble() * 1.5;
final double xSpeed = 40;
final double ySpeed = 50 + _random.nextDouble() * 60;
late List<_Vector> corners = List.generate(4, (i) {
final angle = this.angle + degToRad * (45 + i * 90);
return _Vector(cos(angle), sin(angle));
});
double time = _random.nextDouble();
final Color frontColor;
late final Color backColor = Color.alphaBlend(backSideBlend, frontColor);
final paint = Paint()..style = PaintingStyle.fill;
_PaperSnipping({
required this.frontColor,
required Size bounds,
}) : _bounds = bounds;
void draw(Canvas canvas) {
if (cosA > 0) {
paint.color = frontColor;
} else {
paint.color = backColor;
}
final path = Path()
..addPolygon(
List.generate(
4,
(index) => Offset(
position.x + corners[index].x * size,
position.y + corners[index].y * size * cosA,
)),
true,
);
canvas.drawPath(path, paint);
}
void update(double dt) {
time += dt;
rotation += rotationSpeed * dt;
cosA = cos(degToRad * rotation);
position.x += cos(time * oscillationSpeed) * xSpeed * dt;
position.y += ySpeed * dt;
if (position.y > _bounds.height) {
// Move the snipping back to the top.
position.x = _random.nextDouble() * _bounds.width;
position.y = 0;
}
}
void updateBounds(Size newBounds) {
if (!newBounds.contains(Offset(position.x, position.y))) {
position.x = _random.nextDouble() * newBounds.width;
position.y = _random.nextDouble() * newBounds.height;
}
_bounds = newBounds;
}
}
class _Vector {
double x, y;
_Vector(this.x, this.y);
}

View File

@@ -0,0 +1,124 @@
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:logging/logging.dart';
CustomTransitionPage<T> buildMyTransition<T>({
required Widget child,
required Color color,
String? name,
Object? arguments,
String? restorationId,
LocalKey? key,
}) {
return CustomTransitionPage<T>(
child: child,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return _MyReveal(
animation: animation,
color: color,
child: child,
);
},
key: key,
name: name,
arguments: arguments,
restorationId: restorationId,
transitionDuration: const Duration(milliseconds: 700),
);
}
class _MyReveal extends StatefulWidget {
final Widget child;
final Animation<double> animation;
final Color color;
const _MyReveal({
required this.child,
required this.animation,
required this.color,
Key? key,
}) : super(key: key);
@override
State<_MyReveal> createState() => _MyRevealState();
}
class _MyRevealState extends State<_MyReveal> {
static final _log = Logger('_InkRevealState');
bool _finished = false;
final _tween = Tween(begin: const Offset(0, -1), end: Offset.zero);
@override
void initState() {
super.initState();
widget.animation.addStatusListener(_statusListener);
}
@override
void didUpdateWidget(covariant _MyReveal oldWidget) {
if (oldWidget.animation != widget.animation) {
oldWidget.animation.removeStatusListener(_statusListener);
widget.animation.addStatusListener(_statusListener);
}
super.didUpdateWidget(oldWidget);
}
@override
void dispose() {
widget.animation.removeStatusListener(_statusListener);
super.dispose();
}
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
SlideTransition(
position: _tween.animate(
CurvedAnimation(
parent: widget.animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeOutCubic,
),
),
child: Container(
color: widget.color,
),
),
AnimatedOpacity(
opacity: _finished ? 1 : 0,
duration: const Duration(milliseconds: 300),
child: widget.child,
),
],
);
}
void _statusListener(AnimationStatus status) {
_log.fine(() => 'status: $status');
switch (status) {
case AnimationStatus.completed:
setState(() {
_finished = true;
});
break;
case AnimationStatus.forward:
case AnimationStatus.dismissed:
case AnimationStatus.reverse:
setState(() {
_finished = false;
});
break;
}
}
}

View File

@@ -0,0 +1,37 @@
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'package:flutter/material.dart';
/// A palette of colors to be used in the game.
///
/// The reason we're not going with something like Material Design's
/// `Theme` is simply that this is simpler to work with and yet gives
/// us everything we need for a game.
///
/// Games generally have more radical color palettes than apps. For example,
/// every level of a game can have radically different colors.
/// At the same time, games rarely support dark mode.
///
/// Colors taken from this fun palette:
/// https://lospec.com/palette-list/crayola84
///
/// Colors here are implemented as getters so that hot reloading works.
/// In practice, we could just as easily implement the colors
/// as `static const`. But this way the palette is more malleable:
/// we could allow players to customize colors, for example,
/// or even get the colors from the network.
class Palette {
Color get pen => const Color(0xff1d75fb);
Color get darkPen => const Color(0xFF0050bc);
Color get redPen => const Color(0xFFd10841);
Color get inkFullOpacity => const Color(0xff352b42);
Color get ink => const Color(0xee352b42);
Color get backgroundMain => const Color(0xffffffd1);
Color get backgroundLevelSelection => const Color(0xffa2dcc7);
Color get backgroundPlaySession => const Color(0xffffebb5);
Color get background4 => const Color(0xffffd7ff);
Color get backgroundSettings => const Color(0xffbfc8e3);
Color get trueWhite => const Color(0xffffffff);
}

View File

@@ -0,0 +1,125 @@
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'package:flutter/material.dart';
/// A widget that makes it easy to create a screen with a square-ish
/// main area, a smaller menu area, and a small area for a message on top.
/// It works in both orientations on mobile- and tablet-sized screens.
class ResponsiveScreen extends StatelessWidget {
/// This is the "hero" of the screen. It's more or less square, and will
/// be placed in the visual "center" of the screen.
final Widget squarishMainArea;
/// The second-largest area after [squarishMainArea]. It can be narrow
/// or wide.
final Widget rectangularMenuArea;
/// An area reserved for some static text close to the top of the screen.
final Widget topMessageArea;
/// How much bigger should the [squarishMainArea] be compared to the other
/// elements.
final double mainAreaProminence;
const ResponsiveScreen({
required this.squarishMainArea,
required this.rectangularMenuArea,
this.topMessageArea = const SizedBox.shrink(),
this.mainAreaProminence = 0.8,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
// This widget wants to fill the whole screen.
final size = constraints.biggest;
final padding = EdgeInsets.all(size.shortestSide / 30);
if (size.height >= size.width) {
// "Portrait" / "mobile" mode.
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SafeArea(
bottom: false,
child: Padding(
padding: padding,
child: topMessageArea,
),
),
Expanded(
flex: (mainAreaProminence * 100).round(),
child: SafeArea(
top: false,
bottom: false,
minimum: padding,
child: squarishMainArea,
),
),
SafeArea(
top: false,
maintainBottomViewPadding: true,
child: Padding(
padding: padding,
child: rectangularMenuArea,
),
),
],
);
} else {
// "Landscape" / "tablet" mode.
final isLarge = size.width > 900;
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
flex: isLarge ? 7 : 5,
child: SafeArea(
right: false,
maintainBottomViewPadding: true,
minimum: padding,
child: squarishMainArea,
),
),
Expanded(
flex: 3,
child: Column(
children: [
SafeArea(
bottom: false,
left: false,
maintainBottomViewPadding: true,
child: Padding(
padding: padding,
child: topMessageArea,
),
),
Expanded(
child: SafeArea(
top: false,
left: false,
maintainBottomViewPadding: true,
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: padding,
child: rectangularMenuArea,
),
),
),
)
],
),
),
],
);
}
},
);
}
}

View File

@@ -0,0 +1,18 @@
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'package:flutter/material.dart';
/// Shows [message] in a snack bar as long as a [ScaffoldMessengerState]
/// with global key [scaffoldMessengerKey] is anywhere in the widget tree.
void showSnackBar(String message) {
final messenger = scaffoldMessengerKey.currentState;
messenger?.showSnackBar(
SnackBar(content: Text(message)),
);
}
/// Use this when creating [MaterialApp] if you want [showSnackBar] to work.
final GlobalKey<ScaffoldMessengerState> scaffoldMessengerKey =
GlobalKey(debugLabel: 'scaffoldMessengerKey');

View File

@@ -0,0 +1,73 @@
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import '../ads/ads_controller.dart';
import '../ads/banner_ad_widget.dart';
import '../games_services/score.dart';
import '../in_app_purchase/in_app_purchase.dart';
import '../style/palette.dart';
import '../style/responsive_screen.dart';
class WinGameScreen extends StatelessWidget {
final Score score;
const WinGameScreen({
Key? key,
required this.score,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final adsControllerAvailable = context.watch<AdsController?>() != null;
final adsRemoved =
context.watch<InAppPurchaseController?>()?.adRemoval.active ?? false;
final palette = context.watch<Palette>();
const gap = SizedBox(height: 10);
return Scaffold(
backgroundColor: palette.backgroundPlaySession,
body: ResponsiveScreen(
squarishMainArea: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
if (adsControllerAvailable && !adsRemoved) ...[
const Expanded(
child: Center(
child: BannerAdWidget(),
),
),
],
gap,
const Center(
child: Text(
'You won!',
style: TextStyle(fontFamily: 'Permanent Marker', fontSize: 50),
),
),
gap,
Center(
child: Text(
'Score: ${score.score}\n'
'Time: ${score.formattedTime}',
style: const TextStyle(
fontFamily: 'Permanent Marker', fontSize: 20),
),
),
],
),
rectangularMenuArea: ElevatedButton(
onPressed: () {
GoRouter.of(context).pop();
},
child: const Text('Continue'),
),
),
);
}
}