From a78d7d286098c256a24db6d821ffab99fdd03684 Mon Sep 17 00:00:00 2001 From: Filip Hracek Date: Thu, 21 Dec 2023 08:16:42 +0100 Subject: [PATCH] Integrate games cookbook recipes with the code excerpts machinery (#9950) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the code featured in the new game-related cookbooks CI-tested. _Issues fixed by this PR (if any):_ Fixes #9822. ## Presubmit checklist - [x] This PR doesn’t contain automatically generated corrections (Grammarly or similar). - [x] This PR follows the [Google Developer Documentation Style Guidelines](https://developers.google.com/style) — for example, it doesn’t use _i.e._ or _e.g._, and it avoids _I_ and _we_ (first person). - [x] This PR uses [semantic line breaks](https://github.com/dart-lang/site-shared/blob/main/doc/writing-for-dart-and-flutter-websites.md#semantic-line-breaks) of 80 characters or fewer. --- .../analysis_options.yaml | 5 + .../lib/games_services_controller.dart | 113 ++++++++++ .../lib/various.dart | 44 ++++ .../achievements_leaderboards/pubspec.yaml | 19 ++ .../games/firestore_multiplayer/README.md | 2 + .../analysis_options.yaml | 5 + .../lib/firebase_options.dart | 80 +++++++ .../lib/game_internals/board_state.dart | 20 ++ .../lib/game_internals/playing_area.dart | 23 ++ .../lib/game_internals/playing_card.dart | 12 + .../games/firestore_multiplayer/lib/main.dart | 39 ++++ .../lib/multiplayer/firestore_controller.dart | 147 +++++++++++++ .../lib/play_session/play_session_screen.dart | 64 ++++++ .../games/firestore_multiplayer/pubspec.yaml | 22 ++ .../google_mobile_ads/analysis_options.yaml | 9 + .../plugins/google_mobile_ads/lib/main.dart | 22 ++ .../google_mobile_ads/lib/my_banner_ad.dart | 96 ++++++++ .../plugins/google_mobile_ads/pubspec.yaml | 18 ++ .../games/achievements-leaderboard.md | 17 +- src/cookbook/games/firestore-multiplayer.md | 28 ++- src/cookbook/plugins/google-mobile-ads.md | 208 ++++++++++-------- 21 files changed, 885 insertions(+), 108 deletions(-) create mode 100644 examples/cookbook/games/achievements_leaderboards/analysis_options.yaml create mode 100644 examples/cookbook/games/achievements_leaderboards/lib/games_services_controller.dart create mode 100644 examples/cookbook/games/achievements_leaderboards/lib/various.dart create mode 100644 examples/cookbook/games/achievements_leaderboards/pubspec.yaml create mode 100644 examples/cookbook/games/firestore_multiplayer/README.md create mode 100644 examples/cookbook/games/firestore_multiplayer/analysis_options.yaml create mode 100644 examples/cookbook/games/firestore_multiplayer/lib/firebase_options.dart create mode 100644 examples/cookbook/games/firestore_multiplayer/lib/game_internals/board_state.dart create mode 100644 examples/cookbook/games/firestore_multiplayer/lib/game_internals/playing_area.dart create mode 100644 examples/cookbook/games/firestore_multiplayer/lib/game_internals/playing_card.dart create mode 100644 examples/cookbook/games/firestore_multiplayer/lib/main.dart create mode 100644 examples/cookbook/games/firestore_multiplayer/lib/multiplayer/firestore_controller.dart create mode 100644 examples/cookbook/games/firestore_multiplayer/lib/play_session/play_session_screen.dart create mode 100644 examples/cookbook/games/firestore_multiplayer/pubspec.yaml create mode 100644 examples/cookbook/plugins/google_mobile_ads/analysis_options.yaml create mode 100644 examples/cookbook/plugins/google_mobile_ads/lib/main.dart create mode 100644 examples/cookbook/plugins/google_mobile_ads/lib/my_banner_ad.dart create mode 100644 examples/cookbook/plugins/google_mobile_ads/pubspec.yaml diff --git a/examples/cookbook/games/achievements_leaderboards/analysis_options.yaml b/examples/cookbook/games/achievements_leaderboards/analysis_options.yaml new file mode 100644 index 0000000000..eee60e0f5a --- /dev/null +++ b/examples/cookbook/games/achievements_leaderboards/analysis_options.yaml @@ -0,0 +1,5 @@ +# Take our settings from the example_utils analysis_options.yaml file. +# If necessary for a particular example, this file can also include +# overrides for individual lints. + +include: package:example_utils/analysis.yaml diff --git a/examples/cookbook/games/achievements_leaderboards/lib/games_services_controller.dart b/examples/cookbook/games/achievements_leaderboards/lib/games_services_controller.dart new file mode 100644 index 0000000000..571eb10b88 --- /dev/null +++ b/examples/cookbook/games/achievements_leaderboards/lib/games_services_controller.dart @@ -0,0 +1,113 @@ +import 'dart:async'; + +import 'package:games_services/games_services.dart'; +import 'package:logging/logging.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 _signedInCompleter = Completer(); + + Future 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 awardAchievement( + {required String iOS, required String android}) async { + if (!await signedIn) { + _log.warning('Trying to award achievement when not logged in.'); + return; + } + + try { + await GamesServices.unlock( + achievement: Achievement( + androidID: android, + iOSID: iOS, + ), + ); + } catch (e) { + _log.severe('Cannot award achievement: $e'); + } + } + + /// Signs into the underlying games service. + Future initialize() async { + try { + await 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 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 showAchievements() async { + if (!await signedIn) { + _log.severe('Trying to show achievements when not logged in.'); + return; + } + + try { + await GamesServices.showAchievements(); + } catch (e) { + _log.severe('Cannot show achievements: $e'); + } + } + + /// Launches the platform's UI overlay with leaderboard(s). + Future showLeaderboard() async { + if (!await signedIn) { + _log.severe('Trying to show leaderboard when not logged in.'); + return; + } + + try { + await 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 submitLeaderboardScore(int score) async { + if (!await signedIn) { + _log.warning('Trying to submit leaderboard when not logged in.'); + return; + } + + _log.info('Submitting $score to leaderboard.'); + + try { + await GamesServices.submitScore( + score: Score( + // TODO: When ready, change these leaderboard IDs. + iOSLeaderboardID: 'some_id_from_app_store', + androidLeaderboardID: 'sOmE_iD_fRoM_gPlAy', + value: score, + ), + ); + } catch (e) { + _log.severe('Cannot submit leaderboard score: $e'); + } + } +} \ No newline at end of file diff --git a/examples/cookbook/games/achievements_leaderboards/lib/various.dart b/examples/cookbook/games/achievements_leaderboards/lib/various.dart new file mode 100644 index 0000000000..e671cb6603 --- /dev/null +++ b/examples/cookbook/games/achievements_leaderboards/lib/various.dart @@ -0,0 +1,44 @@ +// ignore_for_file: unused_catch_clause + +import 'package:flutter/services.dart'; +import 'package:games_services/games_services.dart'; + +void main() async { + // #docregion signIn + try { + await GamesServices.signIn(); + } on PlatformException catch (e) { + // ... deal with failures ... + } + // #enddocregion signIn + + // #docregion unlock + await GamesServices.unlock( + achievement: Achievement( + androidID: 'your android id', + iOSID: 'your ios id', + ), + ); + // #enddocregion unlock + + // #docregion showAchievements + await GamesServices.showAchievements(); + // #enddocregion showAchievements + + // #docregion submitScore + await GamesServices.submitScore( + score: Score( + iOSLeaderboardID: 'some_id_from_app_store', + androidLeaderboardID: 'sOmE_iD_fRoM_gPlAy', + value: 100, + ), + ); + // #enddocregion submitScore + + // #docregion showLeaderboards + await GamesServices.showLeaderboards( + iOSLeaderboardID: 'some_id_from_app_store', + androidLeaderboardID: 'sOmE_iD_fRoM_gPlAy', + ); + // #enddocregion showLeaderboards +} diff --git a/examples/cookbook/games/achievements_leaderboards/pubspec.yaml b/examples/cookbook/games/achievements_leaderboards/pubspec.yaml new file mode 100644 index 0000000000..0bf34949e5 --- /dev/null +++ b/examples/cookbook/games/achievements_leaderboards/pubspec.yaml @@ -0,0 +1,19 @@ +name: games_services_example +description: Games services + +environment: + sdk: ^3.2.0 + +dependencies: + flutter: + sdk: flutter + + games_services: ^4.0.0 + logging: ^1.2.0 + +dev_dependencies: + example_utils: + path: ../../../example_utils + +flutter: + uses-material-design: true diff --git a/examples/cookbook/games/firestore_multiplayer/README.md b/examples/cookbook/games/firestore_multiplayer/README.md new file mode 100644 index 0000000000..e81d8c50cd --- /dev/null +++ b/examples/cookbook/games/firestore_multiplayer/README.md @@ -0,0 +1,2 @@ +This is a stripped down version of the multiplayer sample +in https://github.com/flutter/games/tree/main/samples/multiplayer. diff --git a/examples/cookbook/games/firestore_multiplayer/analysis_options.yaml b/examples/cookbook/games/firestore_multiplayer/analysis_options.yaml new file mode 100644 index 0000000000..eee60e0f5a --- /dev/null +++ b/examples/cookbook/games/firestore_multiplayer/analysis_options.yaml @@ -0,0 +1,5 @@ +# Take our settings from the example_utils analysis_options.yaml file. +# If necessary for a particular example, this file can also include +# overrides for individual lints. + +include: package:example_utils/analysis.yaml diff --git a/examples/cookbook/games/firestore_multiplayer/lib/firebase_options.dart b/examples/cookbook/games/firestore_multiplayer/lib/firebase_options.dart new file mode 100644 index 0000000000..92af9100af --- /dev/null +++ b/examples/cookbook/games/firestore_multiplayer/lib/firebase_options.dart @@ -0,0 +1,80 @@ +// File generated by FlutterFire CLI. +// ignore_for_file: lines_longer_than_80_chars, avoid_classes_with_only_static_members +import 'package:firebase_core/firebase_core.dart' show FirebaseOptions; +import 'package:flutter/foundation.dart' + show defaultTargetPlatform, kIsWeb, TargetPlatform; + +/// Default [FirebaseOptions] for use with your Firebase apps. +/// +/// Example: +/// ```dart +/// import 'firebase_options.dart'; +/// // ... +/// await Firebase.initializeApp( +/// options: DefaultFirebaseOptions.currentPlatform, +/// ); +/// ``` +class DefaultFirebaseOptions { + static FirebaseOptions get currentPlatform { + if (kIsWeb) { + return web; + } + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return android; + case TargetPlatform.iOS: + return ios; + case TargetPlatform.macOS: + return macos; + case TargetPlatform.windows: + throw UnsupportedError( + 'DefaultFirebaseOptions have not been configured for windows - ' + 'you can reconfigure this by running the FlutterFire CLI again.', + ); + case TargetPlatform.linux: + throw UnsupportedError( + 'DefaultFirebaseOptions have not been configured for linux - ' + 'you can reconfigure this by running the FlutterFire CLI again.', + ); + default: + throw UnsupportedError( + 'DefaultFirebaseOptions are not supported for this platform.', + ); + } + } + + static const FirebaseOptions web = FirebaseOptions( + apiKey: 'blahblahbla', + appId: '1:123456:web:blahblahbla', + messagingSenderId: '123456', + projectId: 'card-game-deadbeef', + authDomain: 'card-game-deadbeef.firebaseapp.com', + storageBucket: 'card-game-deadbeef.appspot.com', + ); + + static const FirebaseOptions android = FirebaseOptions( + apiKey: 'blahblahbla', + appId: '1:123456:android:blahblahbla', + messagingSenderId: '123456', + projectId: 'card-game-deadbeef', + storageBucket: 'card-game-deadbeef.appspot.com', + ); + + static const FirebaseOptions ios = FirebaseOptions( + apiKey: 'blahblahbla', + appId: '1:123456:ios:blahblahbla', + messagingSenderId: '123456', + projectId: 'card-game-deadbeef', + storageBucket: 'card-game-deadbeef.appspot.com', + iosBundleId: 'com.example.card', + ); + + static const FirebaseOptions macos = FirebaseOptions( + apiKey: 'blahblahbla', + appId: '1:123456:ios:blahblahbla', + messagingSenderId: '123456', + projectId: 'card-game-deadbeef', + storageBucket: 'card-game-deadbeef.appspot.com', + iosBundleId: 'com.example.card.RunnerTests', + ); +} diff --git a/examples/cookbook/games/firestore_multiplayer/lib/game_internals/board_state.dart b/examples/cookbook/games/firestore_multiplayer/lib/game_internals/board_state.dart new file mode 100644 index 0000000000..5c755d36f5 --- /dev/null +++ b/examples/cookbook/games/firestore_multiplayer/lib/game_internals/board_state.dart @@ -0,0 +1,20 @@ +// This file has only enough in it to satisfy `firestore_controller.dart`. + +import 'package:flutter/foundation.dart'; + +import 'playing_area.dart'; + +class BoardState { + final VoidCallback onWin; + + final PlayingArea areaOne = PlayingArea(); + + final PlayingArea areaTwo = PlayingArea(); + + BoardState({required this.onWin}); + + void dispose() { + areaOne.dispose(); + areaTwo.dispose(); + } +} diff --git a/examples/cookbook/games/firestore_multiplayer/lib/game_internals/playing_area.dart b/examples/cookbook/games/firestore_multiplayer/lib/game_internals/playing_area.dart new file mode 100644 index 0000000000..0b0c2024fa --- /dev/null +++ b/examples/cookbook/games/firestore_multiplayer/lib/game_internals/playing_area.dart @@ -0,0 +1,23 @@ +// This file has only enough in it to satisfy `firestore_controller.dart`. + +import 'dart:async'; + +import 'playing_card.dart'; + +class PlayingArea { + final List cards = []; + + final StreamController _playerChanges = + StreamController.broadcast(); + + PlayingArea(); + + Stream get playerChanges => _playerChanges.stream; + + void dispose() { + _playerChanges.close(); + } + + void replaceWith(List cards) { + } +} diff --git a/examples/cookbook/games/firestore_multiplayer/lib/game_internals/playing_card.dart b/examples/cookbook/games/firestore_multiplayer/lib/game_internals/playing_card.dart new file mode 100644 index 0000000000..8be4c52894 --- /dev/null +++ b/examples/cookbook/games/firestore_multiplayer/lib/game_internals/playing_card.dart @@ -0,0 +1,12 @@ +// This file has only enough in it to satisfy `firestore_controller.dart`. +class PlayingCard { + const PlayingCard(); + + factory PlayingCard.fromJson(Map json) { + return const PlayingCard(); + } + + Map toJson() => { + // Nothing + }; +} diff --git a/examples/cookbook/games/firestore_multiplayer/lib/main.dart b/examples/cookbook/games/firestore_multiplayer/lib/main.dart new file mode 100644 index 0000000000..c3c44c0ef9 --- /dev/null +++ b/examples/cookbook/games/firestore_multiplayer/lib/main.dart @@ -0,0 +1,39 @@ +// ignore_for_file: directives_ordering, prefer_const_constructors + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +// #docregion imports +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_core/firebase_core.dart'; + +import 'firebase_options.dart'; +// #enddocregion imports + +void main() async { + // #docregion initializeApp + WidgetsFlutterBinding.ensureInitialized(); + + await Firebase.initializeApp( + options: DefaultFirebaseOptions.currentPlatform, + ); + // #enddocregion initializeApp + + // #docregion runApp + runApp( + Provider.value( + value: FirebaseFirestore.instance, + child: MyApp(), + ), + ); + // #enddocregion runApp +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return const Placeholder(); + } +} diff --git a/examples/cookbook/games/firestore_multiplayer/lib/multiplayer/firestore_controller.dart b/examples/cookbook/games/firestore_multiplayer/lib/multiplayer/firestore_controller.dart new file mode 100644 index 0000000000..c108355957 --- /dev/null +++ b/examples/cookbook/games/firestore_multiplayer/lib/multiplayer/firestore_controller.dart @@ -0,0 +1,147 @@ +import 'dart:async'; + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:flutter/foundation.dart'; +import 'package:logging/logging.dart'; + +import '../game_internals/board_state.dart'; +import '../game_internals/playing_area.dart'; +import '../game_internals/playing_card.dart'; + +class FirestoreController { + static final _log = Logger('FirestoreController'); + + final FirebaseFirestore instance; + + final BoardState boardState; + + /// For now, there is only one match. But in order to be ready + /// for match-making, put it in a Firestore collection called matches. + late final _matchRef = instance.collection('matches').doc('match_1'); + + late final _areaOneRef = _matchRef + .collection('areas') + .doc('area_one') + .withConverter>( + fromFirestore: _cardsFromFirestore, toFirestore: _cardsToFirestore); + + late final _areaTwoRef = _matchRef + .collection('areas') + .doc('area_two') + .withConverter>( + fromFirestore: _cardsFromFirestore, toFirestore: _cardsToFirestore); + + StreamSubscription? _areaOneFirestoreSubscription; + StreamSubscription? _areaTwoFirestoreSubscription; + + StreamSubscription? _areaOneLocalSubscription; + StreamSubscription? _areaTwoLocalSubscription; + + FirestoreController({required this.instance, required this.boardState}) { + // Subscribe to the remote changes (from Firestore). + _areaOneFirestoreSubscription = _areaOneRef.snapshots().listen((snapshot) { + _updateLocalFromFirestore(boardState.areaOne, snapshot); + }); + _areaTwoFirestoreSubscription = _areaTwoRef.snapshots().listen((snapshot) { + _updateLocalFromFirestore(boardState.areaTwo, snapshot); + }); + + // Subscribe to the local changes in game state. + _areaOneLocalSubscription = boardState.areaOne.playerChanges.listen((_) { + _updateFirestoreFromLocalAreaOne(); + }); + _areaTwoLocalSubscription = boardState.areaTwo.playerChanges.listen((_) { + _updateFirestoreFromLocalAreaTwo(); + }); + + _log.fine('Initialized'); + } + + void dispose() { + _areaOneFirestoreSubscription?.cancel(); + _areaTwoFirestoreSubscription?.cancel(); + _areaOneLocalSubscription?.cancel(); + _areaTwoLocalSubscription?.cancel(); + + _log.fine('Disposed'); + } + + /// Takes the raw JSON snapshot coming from Firestore and attempts to + /// convert it into a list of [PlayingCard]s. + List _cardsFromFirestore( + DocumentSnapshot> snapshot, + SnapshotOptions? options, + ) { + final data = snapshot.data()?['cards'] as List?; + + if (data == null) { + _log.info('No data found on Firestore, returning empty list'); + return []; + } + + final list = List.castFrom>(data); + + try { + return list.map((raw) => PlayingCard.fromJson(raw)).toList(); + } catch (e) { + throw FirebaseControllerException( + 'Failed to parse data from Firestore: $e'); + } + } + + /// Takes a list of [PlayingCard]s and converts it into a JSON object + /// that can be saved into Firestore. + Map _cardsToFirestore( + List cards, + SetOptions? options, + ) { + return {'cards': cards.map((c) => c.toJson()).toList()}; + } + + /// Updates Firestore with the local state of [area]. + Future _updateFirestoreFromLocal( + PlayingArea area, DocumentReference> ref) async { + try { + _log.fine('Updating Firestore with local data (${area.cards}) ...'); + await ref.set(area.cards); + _log.fine('... done updating.'); + } catch (e) { + throw FirebaseControllerException( + 'Failed to update Firestore with local data (${area.cards}): $e'); + } + } + + /// Sends the local state of `boardState.areaOne` to Firestore. + void _updateFirestoreFromLocalAreaOne() { + _updateFirestoreFromLocal(boardState.areaOne, _areaOneRef); + } + + /// Sends the local state of `boardState.areaTwo` to Firestore. + void _updateFirestoreFromLocalAreaTwo() { + _updateFirestoreFromLocal(boardState.areaTwo, _areaTwoRef); + } + + /// Updates the local state of [area] with the data from Firestore. + void _updateLocalFromFirestore( + PlayingArea area, DocumentSnapshot> snapshot) { + _log.fine('Received new data from Firestore (${snapshot.data()})'); + + final cards = snapshot.data() ?? []; + + if (listEquals(cards, area.cards)) { + _log.fine('No change'); + } else { + _log.fine('Updating local data with Firestore data ($cards)'); + area.replaceWith(cards); + } + } +} + +class FirebaseControllerException implements Exception { + final String message; + + FirebaseControllerException(this.message); + + @override + String toString() => 'FirebaseControllerException: $message'; +} diff --git a/examples/cookbook/games/firestore_multiplayer/lib/play_session/play_session_screen.dart b/examples/cookbook/games/firestore_multiplayer/lib/play_session/play_session_screen.dart new file mode 100644 index 0000000000..15242fb66d --- /dev/null +++ b/examples/cookbook/games/firestore_multiplayer/lib/play_session/play_session_screen.dart @@ -0,0 +1,64 @@ +// ignore_for_file: directives_ordering + +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart' hide Level; +import 'package:provider/provider.dart'; + +import '../game_internals/board_state.dart'; + +// #docregion imports +import 'package:cloud_firestore/cloud_firestore.dart'; +import '../multiplayer/firestore_controller.dart'; +// #enddocregion imports + +class PlaySessionScreen extends StatefulWidget { + const PlaySessionScreen({super.key}); + + @override + State createState() => _PlaySessionScreenState(); +} + +class _PlaySessionScreenState extends State { + static final _log = Logger('PlaySessionScreen'); + + late final BoardState _boardState; + + // #docregion controller + FirestoreController? _firestoreController; + // #enddocregion controller + + @override + Widget build(BuildContext context) { + return const Placeholder(); + } + + @override + void dispose() { + _boardState.dispose(); + // #docregion dispose + _firestoreController?.dispose(); + // #enddocregion dispose + super.dispose(); + } + + @override + void initState() { + super.initState(); + _boardState = BoardState(onWin: _playerWon); + + // #docregion initState + final firestore = context.read(); + if (firestore == null) { + _log.warning("Firestore instance wasn't provided. " + 'Running without _firestoreController.'); + } else { + _firestoreController = FirestoreController( + instance: firestore, + boardState: _boardState, + ); + } + // #enddocregion initState + } + + void _playerWon() {} +} diff --git a/examples/cookbook/games/firestore_multiplayer/pubspec.yaml b/examples/cookbook/games/firestore_multiplayer/pubspec.yaml new file mode 100644 index 0000000000..2dc52e48ed --- /dev/null +++ b/examples/cookbook/games/firestore_multiplayer/pubspec.yaml @@ -0,0 +1,22 @@ +name: firestore_multiplayer +description: Firestore multiplayer + +environment: + sdk: ^3.2.0 + +dependencies: + flutter: + sdk: flutter + + async: ^2.11.0 + cloud_firestore: ^4.13.1 + firebase_core: ^2.22.0 + logging: ^1.2.0 + provider: ^6.0.5 + +dev_dependencies: + example_utils: + path: ../../../example_utils + +flutter: + uses-material-design: true diff --git a/examples/cookbook/plugins/google_mobile_ads/analysis_options.yaml b/examples/cookbook/plugins/google_mobile_ads/analysis_options.yaml new file mode 100644 index 0000000000..f19b3149de --- /dev/null +++ b/examples/cookbook/plugins/google_mobile_ads/analysis_options.yaml @@ -0,0 +1,9 @@ +# Take our settings from the example_utils analysis_options.yaml file. +# If necessary for a particular example, this file can also include +# overrides for individual lints. + +include: package:example_utils/analysis.yaml + +linter: + rules: + prefer_const_constructors: false diff --git a/examples/cookbook/plugins/google_mobile_ads/lib/main.dart b/examples/cookbook/plugins/google_mobile_ads/lib/main.dart new file mode 100644 index 0000000000..be2a623df5 --- /dev/null +++ b/examples/cookbook/plugins/google_mobile_ads/lib/main.dart @@ -0,0 +1,22 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:google_mobile_ads/google_mobile_ads.dart'; + +// #docregion main +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + unawaited(MobileAds.instance.initialize()); + + runApp(MyApp()); +} +// #enddocregion main + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return const Placeholder(); + } +} diff --git a/examples/cookbook/plugins/google_mobile_ads/lib/my_banner_ad.dart b/examples/cookbook/plugins/google_mobile_ads/lib/my_banner_ad.dart new file mode 100644 index 0000000000..b0516a5f2c --- /dev/null +++ b/examples/cookbook/plugins/google_mobile_ads/lib/my_banner_ad.dart @@ -0,0 +1,96 @@ +import 'dart:io'; + +import 'package:flutter/widgets.dart'; +import 'package:google_mobile_ads/google_mobile_ads.dart'; + +class MyBannerAdWidget extends StatefulWidget { + /// The requested size of the banner. Defaults to [AdSize.banner]. + final AdSize adSize; + + /// The AdMob ad unit to show. + /// + /// TODO: replace this test ad unit with your own ad unit + // #docregion adUnitId + final String adUnitId = Platform.isAndroid + // Use this ad unit on Android... + ? 'ca-app-pub-3940256099942544/6300978111' + // ... or this one on iOS. + : 'ca-app-pub-3940256099942544/2934735716'; + // #enddocregion adUnitId + + MyBannerAdWidget({ + super.key, + this.adSize = AdSize.banner, + }); + + @override + State createState() => _MyBannerAdWidgetState(); +} + +class _MyBannerAdWidgetState extends State { + /// The banner ad to show. This is `null` until the ad is actually loaded. + BannerAd? _bannerAd; + + // #docregion build + @override + Widget build(BuildContext context) { + return SafeArea( + child: SizedBox( + width: widget.adSize.width.toDouble(), + height: widget.adSize.height.toDouble(), + child: _bannerAd == null + // Nothing to render yet. + ? SizedBox() + // The actual ad. + : AdWidget(ad: _bannerAd!), + ), + ); + } + // #enddocregion build + + @override + void initState() { + super.initState(); + _loadAd(); + } + + @override + void dispose() { + // #docregion dispose + _bannerAd?.dispose(); + // #enddocregion dispose + super.dispose(); + } + + // #docregion loadAd + /// Loads a banner ad. + void _loadAd() { + final bannerAd = BannerAd( + size: widget.adSize, + adUnitId: widget.adUnitId, + request: const AdRequest(), + listener: BannerAdListener( + // Called when an ad is successfully received. + onAdLoaded: (ad) { + if (!mounted) { + ad.dispose(); + return; + } + setState(() { + _bannerAd = ad as BannerAd; + }); + }, + // Called when an ad request failed. + onAdFailedToLoad: (ad, error) { + debugPrint('BannerAd failed to load: $error'); + ad.dispose(); + }, + ), + ); + + // Start loading. + bannerAd.load(); + } + // #enddocregion loadAd + +} diff --git a/examples/cookbook/plugins/google_mobile_ads/pubspec.yaml b/examples/cookbook/plugins/google_mobile_ads/pubspec.yaml new file mode 100644 index 0000000000..84c2842452 --- /dev/null +++ b/examples/cookbook/plugins/google_mobile_ads/pubspec.yaml @@ -0,0 +1,18 @@ +name: google_mobile_ads_example +description: Google mobile ads + +environment: + sdk: ^3.2.0 + +dependencies: + flutter: + sdk: flutter + + google_mobile_ads: ^4.0.0 + +dev_dependencies: + example_utils: + path: ../../../example_utils + +flutter: + uses-material-design: true diff --git a/src/cookbook/games/achievements-leaderboard.md b/src/cookbook/games/achievements-leaderboard.md index 9002214903..f8aeb2f8e6 100644 --- a/src/cookbook/games/achievements-leaderboard.md +++ b/src/cookbook/games/achievements-leaderboard.md @@ -4,6 +4,8 @@ description: > How to use the games_services plugin to add functionality to your game. --- + + Gamers have various motivations for playing games. In broad strokes, there are four major motivations: [immersion, achievement, cooperation, and competition][]. @@ -133,6 +135,7 @@ have your achievement & leaderboard IDs ready, it's finally Dart time. 2. Before you can do anything else, you have to sign the player into the game service. + ```dart try { await GamesServices.signIn(); @@ -140,6 +143,7 @@ have your achievement & leaderboard IDs ready, it's finally Dart time. // ... deal with failures ... } ``` + < The sign in happens in the background. It takes several seconds, so don't call `signIn()` before `runApp()` or the players will be forced to @@ -163,6 +167,7 @@ handling for clarity. and take note of their IDs. Now you can award any of those achievements from your Dart code: + ```dart await GamesServices.unlock( achievement: Achievement( @@ -178,6 +183,7 @@ handling for clarity. 2. To display the achievements UI from your game, call the `games_services` API: + ```dart await GamesServices.showAchievements(); ``` @@ -202,6 +208,7 @@ leaderboards. Console and App Store Connect, and took note of its ID. Using this ID, you can submit new scores for the player: + ```dart await GamesServices.submitScore( score: Score( @@ -218,10 +225,11 @@ leaderboards. 2. To display the leaderboard as an overlay over your game, make the following call: + ```dart await GamesServices.showLeaderboards( - iOSLeaderboardID: "some_id_from_app_store", - androidLeaderboardID: "sOmE_iD_fRoM_gPlAy", + iOSLeaderboardID: 'some_id_from_app_store', + androidLeaderboardID: 'sOmE_iD_fRoM_gPlAy', ); ``` @@ -246,6 +254,7 @@ Each game has different needs from game services. To start, you might want to create this controller in order to keep all achievements & leaderboards logic in one place: + ```dart import 'dart:async'; @@ -329,8 +338,8 @@ class GamesServicesController { try { await GamesServices.showLeaderboards( // TODO: When ready, change both these leaderboard IDs. - iOSLeaderboardID: "some_id_from_app_store", - androidLeaderboardID: "sOmE_iD_fRoM_gPlAy", + iOSLeaderboardID: 'some_id_from_app_store', + androidLeaderboardID: 'sOmE_iD_fRoM_gPlAy', ); } catch (e) { _log.severe('Cannot show leaderboard: $e'); diff --git a/src/cookbook/games/firestore-multiplayer.md b/src/cookbook/games/firestore-multiplayer.md index 5536fabf70..3340088396 100644 --- a/src/cookbook/games/firestore-multiplayer.md +++ b/src/cookbook/games/firestore-multiplayer.md @@ -7,6 +7,8 @@ description: > {% include docs/yt_shims.liquid %} + + Multiplayer games need a way to synchronize game states between players. Broadly speaking, two types of multiplayer games exist: @@ -132,19 +134,21 @@ Dart code in that guide, return to this recipe. as well as the `firebase_options.dart` file that was generated by `flutterfire configure` in the previous step. + ```dart import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_core/firebase_core.dart'; - + import 'firebase_options.dart'; ``` 2. Add the following code just above the call to `runApp()` in `lib/main.dart`: + ```dart WidgetsFlutterBinding.ensureInitialized(); - + await Firebase.initializeApp( options: DefaultFirebaseOptions.currentPlatform, ); @@ -162,6 +166,7 @@ Dart code in that guide, return to this recipe. Replace the boilerplate `runApp(MyApp())` with the following: + ```dart runApp( Provider.value( @@ -202,6 +207,7 @@ To create a controller, copy, then paste the following code into a new file called `lib/multiplayer/firestore_controller.dart`. + ```dart import 'dart:async'; @@ -228,13 +234,13 @@ class FirestoreController { .collection('areas') .doc('area_one') .withConverter>( - fromFirestore: _cardsFromFirestore, toFirestore: _cardsToFirestore); + fromFirestore: _cardsFromFirestore, toFirestore: _cardsToFirestore); late final _areaTwoRef = _matchRef .collection('areas') .doc('area_two') .withConverter>( - fromFirestore: _cardsFromFirestore, toFirestore: _cardsToFirestore); + fromFirestore: _cardsFromFirestore, toFirestore: _cardsToFirestore); StreamSubscription? _areaOneFirestoreSubscription; StreamSubscription? _areaTwoFirestoreSubscription; @@ -277,7 +283,7 @@ class FirestoreController { DocumentSnapshot> snapshot, SnapshotOptions? options, ) { - final data = snapshot.data()?['cards']; + final data = snapshot.data()?['cards'] as List?; if (data == null) { _log.info('No data found on Firestore, returning empty list'); @@ -304,7 +310,7 @@ class FirestoreController { } /// Updates Firestore with the local state of [area]. - void _updateFirestoreFromLocal( + Future _updateFirestoreFromLocal( PlayingArea area, DocumentReference> ref) async { try { _log.fine('Updating Firestore with local data (${area.cards}) ...'); @@ -316,12 +322,12 @@ class FirestoreController { } } - /// Sends the local state of [boardState.areaOne] to Firestore. + /// Sends the local state of `boardState.areaOne` to Firestore. void _updateFirestoreFromLocalAreaOne() { _updateFirestoreFromLocal(boardState.areaOne, _areaOneRef); } - /// Sends the local state of [boardState.areaTwo] to Firestore. + /// Sends the local state of `boardState.areaTwo` to Firestore. void _updateFirestoreFromLocalAreaTwo() { _updateFirestoreFromLocal(boardState.areaTwo, _areaTwoRef); } @@ -377,6 +383,7 @@ Notice the following features of this code: 2. Import Firebase and the controller: + ```dart import 'package:cloud_firestore/cloud_firestore.dart'; import '../multiplayer/firestore_controller.dart'; @@ -385,6 +392,7 @@ Notice the following features of this code: 3. Add a nullable field to the `_PlaySessionScreenState` class to contain a controller instance: + ```dart FirestoreController? _firestoreController; ``` @@ -395,11 +403,12 @@ Notice the following features of this code: You added the `FirebaseFirestore` instance to `main.dart` in the *Initialize Firestore* step. + ```dart final firestore = context.read(); if (firestore == null) { _log.warning("Firestore instance wasn't provided. " - "Running without _firestoreController."); + 'Running without _firestoreController.'); } else { _firestoreController = FirestoreController( instance: firestore, @@ -411,6 +420,7 @@ Notice the following features of this code: 5. Dispose of the controller using the `dispose()` method of the same class. + ```dart _firestoreController?.dispose(); ``` diff --git a/src/cookbook/plugins/google-mobile-ads.md b/src/cookbook/plugins/google-mobile-ads.md index 03ac6a87a3..2adffdd172 100644 --- a/src/cookbook/plugins/google-mobile-ads.md +++ b/src/cookbook/plugins/google-mobile-ads.md @@ -4,6 +4,8 @@ short-title: Show ads description: How to use the google_mobile_ads package to show ads in Flutter. --- + + {% comment %} This partly duplicates the AdMob documentation here: https://developers.google.com/admob/flutter/quick-start @@ -142,11 +144,12 @@ You need to initialize the Mobile Ads SDK before loading ads. 1. Call `MobileAds.instance.initialize()` to initialize the Mobile Ads SDK. + ```dart - void main() { + void main() async { WidgetsFlutterBinding.ensureInitialized(); unawaited(MobileAds.instance.initialize()); - + runApp(MyApp()); } ``` @@ -171,26 +174,36 @@ To load a banner ad, construct a `BannerAd` instance, and call `load()` on it. {{site.alert.note}} - In the following code snippet, `adSize` and `adUnitId` have not been - initialized. We will get to that in a later step. + The following code snippet refers to fields such a `adSize`, `adUnitId` + and `_bannerAd`. This will all make more sense in a later step. {{site.alert.end}} - + + ```dart /// Loads a banner ad. void _loadAd() { final bannerAd = BannerAd( - size: adSize, - adUnitId: adUnitId, + size: widget.adSize, + adUnitId: widget.adUnitId, request: const AdRequest(), listener: BannerAdListener( + // Called when an ad is successfully received. onAdLoaded: (ad) { - // TODO: show the ad + if (!mounted) { + ad.dispose(); + return; + } + setState(() { + _bannerAd = ad as BannerAd; + }); }, - onAdFailedToLoad: (ad, err) { + // Called when an ad request failed. + onAdFailedToLoad: (ad, error) { + debugPrint('BannerAd failed to load: $error'); ad.dispose(); }, ), - ); + ); // Start loading. bannerAd.load(); @@ -205,25 +218,26 @@ To view a complete example, check out the last step of this recipe. Once you have a loaded instance of `BannerAd`, use `AdWidget` to show it. ```dart -AdWidget(ad: bannerAd) +AdWidget(ad: _bannerAd) ``` It's a good idea to wrap the widget in a `SafeArea` (so that no part of the ad is obstructed by device notches) and a `SizedBox` (so that it has its specified, constant size before and after loading). + ```dart @override Widget build(BuildContext context) { return SafeArea( child: SizedBox( - width: adSize.width.toDouble(), - height: adSize.height.toDouble(), + width: widget.adSize.width.toDouble(), + height: widget.adSize.height.toDouble(), child: _bannerAd == null - // Nothing to render yet. + // Nothing to render yet. ? SizedBox() - // The actual ad. - : AdWidget(ad: bannerAd), + // The actual ad. + : AdWidget(ad: _bannerAd!), ), ); } @@ -234,8 +248,9 @@ practice for when to call `dispose()` is either after the `AdWidget` is removed from the widget tree or in the `BannerAdListener.onAdFailedToLoad()` callback. + ```dart -bannerAd.dispose(); +_bannerAd?.dispose(); ``` @@ -270,8 +285,9 @@ To show anything beyond test ads, you have to register ad units. 5. Add these *Ad unit IDs* to the constructor of `BannerAd`, depending on the target app platform. + ```dart - final String adUnitId = Platform.isAndroid + final String adUnitId = Platform.isAndroid // Use this ad unit on Android... ? 'ca-app-pub-3940256099942544/6300978111' // ... or this one on iOS. @@ -305,6 +321,7 @@ and [Ad Manager](https://admanager.google.com/). The following code implements a simple stateful widget that loads a banner ad and shows it. + ```dart import 'dart:io'; @@ -312,86 +329,87 @@ import 'package:flutter/widgets.dart'; import 'package:google_mobile_ads/google_mobile_ads.dart'; class MyBannerAdWidget extends StatefulWidget { - /// The requested size of the banner. Defaults to [AdSize.banner]. - final AdSize adSize; - - /// The AdMob ad unit to show. - /// - /// TODO: replace this test ad unit with your own ad unit - final String adUnitId = Platform.isAndroid - // Use this ad unit on Android... - ? 'ca-app-pub-3940256099942544/6300978111' - // ... or this one on iOS. - : 'ca-app-pub-3940256099942544/2934735716'; - - MyBannerAdWidget({ - super.key, - this.adSize = AdSize.banner, - }); - - @override - State createState() => _MyBannerAdWidgetState(); + /// The requested size of the banner. Defaults to [AdSize.banner]. + final AdSize adSize; + + /// The AdMob ad unit to show. + /// + /// TODO: replace this test ad unit with your own ad unit + final String adUnitId = Platform.isAndroid + // Use this ad unit on Android... + ? 'ca-app-pub-3940256099942544/6300978111' + // ... or this one on iOS. + : 'ca-app-pub-3940256099942544/2934735716'; + + MyBannerAdWidget({ + super.key, + this.adSize = AdSize.banner, + }); + + @override + State createState() => _MyBannerAdWidgetState(); } class _MyBannerAdWidgetState extends State { - /// The banner ad to show. This is `null` until the ad is actually loaded. - BannerAd? _bannerAd; - - @override - Widget build(BuildContext context) { - return SafeArea( - child: SizedBox( - width: widget.adSize.width.toDouble(), - height: widget.adSize.height.toDouble(), - child: _bannerAd == null - // Nothing to render yet. - ? SizedBox() - // The actual ad. - : AdWidget(ad: _bannerAd!), - ), - ); - } - - @override - void initState() { - super.initState(); - _loadAd(); - } - - @override - void dispose() { - _bannerAd?.dispose(); - super.dispose(); - } - - /// Loads a banner ad. - void _loadAd() { - final bannerAd = BannerAd( - size: widget.adSize, - adUnitId: widget.adUnitId, - request: const AdRequest(), - listener: BannerAdListener( - // Called when an ad is successfully received. - onAdLoaded: (ad) { - if (!mounted) { - ad.dispose(); - return; - } - setState(() { - _bannerAd = ad as BannerAd; - }); - }, - // Called when an ad request failed. - onAdFailedToLoad: (ad, error) { - debugPrint('BannerAd failed to load: $error'); - ad.dispose(); - }, - ), - ); - - // Start loading. - bannerAd.load(); - } + /// The banner ad to show. This is `null` until the ad is actually loaded. + BannerAd? _bannerAd; + + @override + Widget build(BuildContext context) { + return SafeArea( + child: SizedBox( + width: widget.adSize.width.toDouble(), + height: widget.adSize.height.toDouble(), + child: _bannerAd == null + // Nothing to render yet. + ? SizedBox() + // The actual ad. + : AdWidget(ad: _bannerAd!), + ), + ); + } + + @override + void initState() { + super.initState(); + _loadAd(); + } + + @override + void dispose() { + _bannerAd?.dispose(); + super.dispose(); + } + + /// Loads a banner ad. + void _loadAd() { + final bannerAd = BannerAd( + size: widget.adSize, + adUnitId: widget.adUnitId, + request: const AdRequest(), + listener: BannerAdListener( + // Called when an ad is successfully received. + onAdLoaded: (ad) { + if (!mounted) { + ad.dispose(); + return; + } + setState(() { + _bannerAd = ad as BannerAd; + }); + }, + // Called when an ad request failed. + onAdFailedToLoad: (ad, error) { + debugPrint('BannerAd failed to load: $error'); + ad.dispose(); + }, + ), + ); + + // Start loading. + bannerAd.load(); + } + } ```