-
Notifications
You must be signed in to change notification settings - Fork 3.8k
Add multiplayer counter sample #1273
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dart-launch
Are you sure you want to change the base?
Changes from all commits
ac1ba48
59a2805
fe0a2fd
6e74cea
745370d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| .dart_tool | ||
| .firebaserc | ||
| .firebase | ||
| *-debug.log | ||
|
|
||
| app/.flutter-plugins-dependencies | ||
| app/build | ||
|
|
||
| server/bin/server |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| # Multi-counter | ||
|
|
||
| This is sample code from a talk at Cloud Next 2026. | ||
|
|
||
| [Unify your tech stack with Dart](https://www.googlecloudevents.com/next-vegas/session-library?session_id=3911912&name=unify-your-tech-stack-with-dart) | ||
|
|
||
| This sample contains three projects: | ||
| - `app/` A Flutter application for a shared multi-counter. | ||
| - `server/` A Cloud Functions for Firebase project containing functions for the multi-counter written in Dart. | ||
| - `shared/` A shared Dart package containing logic and models used by both the app and the server. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| include: package:dart_flutter_team_lints/analysis_options.yaml | ||
|
|
||
| linter: | ||
| rules: | ||
| - avoid_redundant_argument_values | ||
| - prefer_expression_function_bodies | ||
| - unnecessary_breaks | ||
| - unnecessary_ignore |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| # This file tracks properties of this Flutter project. | ||
| # Used by Flutter tool to assess capabilities and perform upgrades etc. | ||
| # | ||
| # This file should be version controlled and should not be manually edited. | ||
|
|
||
| version: | ||
| revision: "0c2d316b1565bc97fcf6125fa87dfb90ef05ed8f" | ||
| channel: "main" | ||
|
|
||
| project_type: app | ||
|
|
||
| # Tracks metadata for the flutter migrate command | ||
| migration: | ||
| platforms: | ||
| - platform: root | ||
| create_revision: 0c2d316b1565bc97fcf6125fa87dfb90ef05ed8f | ||
| base_revision: 0c2d316b1565bc97fcf6125fa87dfb90ef05ed8f | ||
| - platform: web | ||
| create_revision: 0c2d316b1565bc97fcf6125fa87dfb90ef05ed8f | ||
| base_revision: 0c2d316b1565bc97fcf6125fa87dfb90ef05ed8f | ||
|
|
||
| # User provided section | ||
|
|
||
| # List of Local paths (relative to this file) that should be | ||
| # ignored by the migrate tool. | ||
| # | ||
| # Files that are not part of the templates will be ignored by default. | ||
| unmanaged_files: | ||
| - 'lib/main.dart' | ||
| - 'ios/Runner.xcodeproj/project.pbxproj' |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| // File generated by FlutterFire CLI. | ||
| // ignore_for_file: type=lint | ||
| import 'package:firebase_core/firebase_core.dart' show FirebaseOptions; | ||
| import 'package:flutter/foundation.dart' show defaultTargetPlatform, kIsWeb; | ||
|
|
||
| /// 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 { | ||
| throw UnsupportedError( | ||
| 'DefaultFirebaseOptions have not been configured for $defaultTargetPlatform - ' | ||
| 'you can reconfigure this by running the FlutterFire CLI again.', | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| import 'package:flutter/material.dart'; | ||
|
|
||
| import 'src/app.dart'; | ||
| import 'src/config_state.dart'; | ||
|
|
||
| void main() async { | ||
| WidgetsFlutterBinding.ensureInitialized(); | ||
|
|
||
| await initializeWorld(); | ||
|
|
||
| runApp(const MyApp()); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| import 'package:flutter/material.dart'; | ||
|
|
||
| import 'router.dart'; | ||
|
|
||
| class MyApp extends StatelessWidget { | ||
| const MyApp({super.key}); | ||
|
|
||
| @override | ||
| Widget build(BuildContext context) => MaterialApp.router( | ||
| debugShowCheckedModeBanner: false, | ||
| title: 'Flutter Demo', | ||
| routerConfig: router, | ||
| theme: ThemeData( | ||
| useMaterial3: true, | ||
| colorScheme: const ColorScheme.light( | ||
| primary: Color(0xFF18181B), // Zinc 900 | ||
| secondary: Color(0xFF3F3F46), // Zinc 700 | ||
| onSecondary: Colors.white, | ||
| ), | ||
| appBarTheme: const AppBarTheme( | ||
| backgroundColor: Colors.white, | ||
| foregroundColor: Color(0xFF18181B), | ||
| elevation: 0, | ||
| ), | ||
| ), | ||
| darkTheme: ThemeData( | ||
| useMaterial3: true, | ||
| colorScheme: const ColorScheme.dark( | ||
| primary: Color(0xFFFAFAFA), // Zinc 50 | ||
| secondary: Color(0xFFD4D4D8), // Zinc 300 | ||
| surface: Color(0xFF09090B), // Zinc 950 | ||
| onPrimary: Color(0xFF18181B), | ||
| onSecondary: Color(0xFF18181B), | ||
| ), | ||
| appBarTheme: const AppBarTheme( | ||
| backgroundColor: Color(0xFF09090B), | ||
| foregroundColor: Color(0xFFFAFAFA), | ||
| elevation: 0, | ||
| ), | ||
| ), | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import 'package:cloud_firestore/cloud_firestore.dart'; | ||
| import 'package:cloud_functions/cloud_functions.dart'; | ||
| import 'package:firebase_auth/firebase_auth.dart'; | ||
| import 'package:firebase_core/firebase_core.dart'; | ||
| import 'package:flutter/foundation.dart'; | ||
| import 'package:multi_counter_shared/multi_counter_shared.dart'; | ||
|
|
||
| import '../firebase_options.dart'; | ||
|
|
||
| Future<void> initializeWorld() async { | ||
| await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); | ||
|
|
||
| if (kDebugMode) { | ||
| await FirebaseAuth.instance.useAuthEmulator('127.0.0.1', 9099); | ||
| FirebaseFirestore.instance.useFirestoreEmulator('127.0.0.1', 8080); | ||
| FirebaseFunctions.instance.useFunctionsEmulator('127.0.0.1', 5001); | ||
| } | ||
| } | ||
|
|
||
| final _options = HttpsCallableOptions(timeout: const Duration(seconds: 15)); | ||
|
|
||
| HttpsCallable get incrementHttpsCallable { | ||
| if (kDebugMode) { | ||
| return FirebaseFunctions.instance.httpsCallable( | ||
| incrementCallable, | ||
| options: _options, | ||
| ); | ||
| } else { | ||
| return FirebaseFunctions.instance.httpsCallableFromUrl( | ||
| 'https://increment-138342796561.us-central1.run.app', | ||
| options: _options, | ||
| ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| const appTitle = 'Multi-Counter'; | ||
| const maxContentWidth = 440.0; | ||
| const double spaceSize = 16; | ||
| const double doubleSpaceSize = spaceSize * 2; | ||
|
|
||
| // TODO: Update this to the final example URI | ||
| const githubDisplayUrl = 'github.com/kevmoo/next26_demo'; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should likely ditch this and the bits that depend on it for the sample! |
||
| final githubUri = Uri.parse('https://$githubDisplayUrl'); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| import 'dart:async'; | ||
|
|
||
| import 'package:firebase_auth/firebase_auth.dart' hide EmailAuthProvider; | ||
| import 'package:flutter/material.dart'; | ||
| import 'package:go_router/go_router.dart'; | ||
|
|
||
| import 'screens/counter/screen.dart'; | ||
| import 'screens/login.dart'; | ||
|
|
||
| final router = GoRouter( | ||
| initialLocation: '/', | ||
| routes: [ | ||
| GoRoute(path: '/', builder: (context, state) => const CounterScreen()), | ||
| GoRoute(path: '/login', builder: (context, state) => const LoginScreen()), | ||
| ], | ||
| redirect: (context, state) async { | ||
| // Ensure we capture the redirect result before checking the auth state. | ||
| await FirebaseAuth.instance.getRedirectResult(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe? I haven't tried it... I think I put it here when fighting the other auth bits... Need to investigate. |
||
|
|
||
| final loggedIn = FirebaseAuth.instance.currentUser != null; | ||
| final loggingIn = state.matchedLocation == '/login'; | ||
|
|
||
| if (!loggedIn) return '/login'; | ||
| if (loggingIn) return '/'; | ||
|
|
||
| return null; | ||
| }, | ||
| refreshListenable: GoRouterRefreshStream( | ||
| FirebaseAuth.instance.authStateChanges(), | ||
| ), | ||
| ); | ||
|
|
||
| class GoRouterRefreshStream extends ChangeNotifier { | ||
| GoRouterRefreshStream(Stream<dynamic> stream) { | ||
| notifyListeners(); | ||
| _subscription = stream.asBroadcastStream().listen( | ||
| (dynamic _) => notifyListeners(), | ||
| ); | ||
| } | ||
|
|
||
| late final StreamSubscription<dynamic> _subscription; | ||
|
|
||
| @override | ||
| void dispose() { | ||
| _subscription.cancel(); | ||
| super.dispose(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| import 'dart:async'; | ||
|
|
||
| import 'package:flutter/material.dart'; | ||
| import 'package:multi_counter_shared/multi_counter_shared.dart'; | ||
|
|
||
| import '../../constants.dart'; | ||
| import '../../widgets/app_scaffold.dart'; | ||
| import 'state.dart'; | ||
|
|
||
| class CounterScreen extends StatefulWidget { | ||
| const CounterScreen({super.key}); | ||
|
|
||
| @override | ||
| State<CounterScreen> createState() => _CounterScreenState(); | ||
| } | ||
|
|
||
| class _CounterScreenState extends State<CounterScreen> { | ||
| final state = CounterState(); | ||
| late final StreamSubscription<IncrementResponse> _sub; | ||
| late final Listenable _merger; | ||
|
|
||
| @override | ||
| void initState() { | ||
| super.initState(); | ||
|
|
||
| _merger = Listenable.merge([state.userCounter, state.globalCounter]); | ||
|
|
||
| ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? | ||
| snackBarController; | ||
|
|
||
| _sub = state.incrementResponseStream.listen((response) { | ||
| if (!mounted) return; | ||
|
|
||
| final message = response.message; | ||
| if (message != null && snackBarController == null) { | ||
| snackBarController = ScaffoldMessenger.of(context).showSnackBar( | ||
| SnackBar( | ||
| content: Text(message), | ||
| backgroundColor: response.success ? null : Colors.red, | ||
| ), | ||
| ); | ||
|
|
||
| snackBarController?.closed.then((reason) { | ||
| snackBarController = null; | ||
| }); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| @override | ||
| void dispose() { | ||
| _sub.cancel(); | ||
| state.dispose(); | ||
| super.dispose(); | ||
| } | ||
|
|
||
| @override | ||
| Widget build(BuildContext context) => AppScaffold( | ||
| child: ListenableBuilder( | ||
| listenable: _merger, | ||
| builder: (context, child) { | ||
| final globalCount = state.globalCounter.value; | ||
| return SingleChildScrollView( | ||
| child: Column( | ||
| mainAxisSize: MainAxisSize.min, | ||
| mainAxisAlignment: MainAxisAlignment.center, | ||
| children: [ | ||
| Text( | ||
| appTitle, | ||
| style: Theme.of(context).textTheme.headlineMedium, | ||
| textAlign: TextAlign.center, | ||
| ), | ||
| _spacer, | ||
| const Text('You have pushed the button this many times:'), | ||
| Text( | ||
| '${state.userCounter.value}', | ||
| style: Theme.of(context).textTheme.headlineMedium, | ||
| ), | ||
| _spacer, | ||
| if (globalCount == null) const Text('...'), | ||
| if (globalCount != null) ...[ | ||
| const Text('Total button pushes:'), | ||
| Text( | ||
| '${globalCount.totalClicks}', | ||
| style: Theme.of(context).textTheme.headlineMedium, | ||
| ), | ||
| _spacer, | ||
| const Text('Total people who have pushed the button:'), | ||
| Text( | ||
| '${globalCount.totalUsers}', | ||
| style: Theme.of(context).textTheme.headlineMedium, | ||
| ), | ||
| ], | ||
| _spacer, | ||
| FloatingActionButton.extended( | ||
| onPressed: state.increment, | ||
| tooltip: 'Increment', | ||
| icon: const Icon(Icons.add), | ||
| label: const Text('Increment'), | ||
| ), | ||
| ], | ||
| ), | ||
| ); | ||
| }, | ||
| ), | ||
| ); | ||
| } | ||
|
|
||
| const _spacer = SizedBox(height: doubleSpaceSize); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should we just put
example.comhere or something?or do we put a
throwin here with a TODO to populate it?