Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .github/workflows/test_dart.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ jobs:
steps:
- uses: actions/checkout@v4

- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: 'stable'

- name: Set up Dart ${{ matrix.dart-version }}
uses: dart-lang/setup-dart@v1.6.0
with:
Expand All @@ -49,7 +54,11 @@ jobs:
for dir in $(find Dart -name pubspec.yaml -exec dirname {} \;); do
echo "::group::Testing $dir"
cd "$dir"
dart pub get
if grep -q "sdk: flutter" pubspec.yaml; then
flutter pub get
else
dart pub get
fi
dart format --set-exit-if-changed .
dart analyze .
cd - > /dev/null
Expand Down
9 changes: 9 additions & 0 deletions Dart/multi_counter/.gitignore
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
10 changes: 10 additions & 0 deletions Dart/multi_counter/README.md
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.
8 changes: 8 additions & 0 deletions Dart/multi_counter/analysis_options.yaml
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
30 changes: 30 additions & 0 deletions Dart/multi_counter/app/.metadata
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'
23 changes: 23 additions & 0 deletions Dart/multi_counter/app/lib/firebase_options.dart
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.',
);
}
}
12 changes: 12 additions & 0 deletions Dart/multi_counter/app/lib/main.dart
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());
}
42 changes: 42 additions & 0 deletions Dart/multi_counter/app/lib/src/app.dart
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,
),
),
);
}
34 changes: 34 additions & 0 deletions Dart/multi_counter/app/lib/src/config_state.dart
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',
Copy link
Copy Markdown

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.com here or something?

or do we put a throw in here with a TODO to populate it?

options: _options,
);
}
}
8 changes: 8 additions & 0 deletions Dart/multi_counter/app/lib/src/constants.dart
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';
Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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');
48 changes: 48 additions & 0 deletions Dart/multi_counter/app/lib/src/router.dart
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();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

getRedirectResult() should typically be called once during app initialization (e.g., in main or a dedicated initialization block), rather than inside the router's redirect function. Calling it here causes it to execute on every navigation, which is inefficient.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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();
}
}
109 changes: 109 additions & 0 deletions Dart/multi_counter/app/lib/src/screens/counter/screen.dart
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);
Loading
Loading