diff --git a/.github/workflows/test_dart.yml b/.github/workflows/test_dart.yml index 751df78226..024cde81bd 100644 --- a/.github/workflows/test_dart.yml +++ b/.github/workflows/test_dart.yml @@ -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: @@ -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 diff --git a/Dart/multi_counter/.gitignore b/Dart/multi_counter/.gitignore new file mode 100644 index 0000000000..bdad04710f --- /dev/null +++ b/Dart/multi_counter/.gitignore @@ -0,0 +1,9 @@ +.dart_tool +.firebaserc +.firebase +*-debug.log + +app/.flutter-plugins-dependencies +app/build + +server/bin/server diff --git a/Dart/multi_counter/README.md b/Dart/multi_counter/README.md new file mode 100644 index 0000000000..8b5935c29a --- /dev/null +++ b/Dart/multi_counter/README.md @@ -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. diff --git a/Dart/multi_counter/analysis_options.yaml b/Dart/multi_counter/analysis_options.yaml new file mode 100644 index 0000000000..9a8cf6f6e6 --- /dev/null +++ b/Dart/multi_counter/analysis_options.yaml @@ -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 diff --git a/Dart/multi_counter/app/.metadata b/Dart/multi_counter/app/.metadata new file mode 100644 index 0000000000..c25593b787 --- /dev/null +++ b/Dart/multi_counter/app/.metadata @@ -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' diff --git a/Dart/multi_counter/app/lib/firebase_options.dart b/Dart/multi_counter/app/lib/firebase_options.dart new file mode 100644 index 0000000000..11c0816586 --- /dev/null +++ b/Dart/multi_counter/app/lib/firebase_options.dart @@ -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.', + ); + } +} diff --git a/Dart/multi_counter/app/lib/main.dart b/Dart/multi_counter/app/lib/main.dart new file mode 100644 index 0000000000..f231f9563c --- /dev/null +++ b/Dart/multi_counter/app/lib/main.dart @@ -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()); +} diff --git a/Dart/multi_counter/app/lib/src/app.dart b/Dart/multi_counter/app/lib/src/app.dart new file mode 100644 index 0000000000..2f9b936db0 --- /dev/null +++ b/Dart/multi_counter/app/lib/src/app.dart @@ -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, + ), + ), + ); +} diff --git a/Dart/multi_counter/app/lib/src/config_state.dart b/Dart/multi_counter/app/lib/src/config_state.dart new file mode 100644 index 0000000000..e6cad38100 --- /dev/null +++ b/Dart/multi_counter/app/lib/src/config_state.dart @@ -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 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, + ); + } +} diff --git a/Dart/multi_counter/app/lib/src/constants.dart b/Dart/multi_counter/app/lib/src/constants.dart new file mode 100644 index 0000000000..fd6f66d381 --- /dev/null +++ b/Dart/multi_counter/app/lib/src/constants.dart @@ -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'; +final githubUri = Uri.parse('https://$githubDisplayUrl'); diff --git a/Dart/multi_counter/app/lib/src/router.dart b/Dart/multi_counter/app/lib/src/router.dart new file mode 100644 index 0000000000..bb46e180f5 --- /dev/null +++ b/Dart/multi_counter/app/lib/src/router.dart @@ -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(); + + 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 stream) { + notifyListeners(); + _subscription = stream.asBroadcastStream().listen( + (dynamic _) => notifyListeners(), + ); + } + + late final StreamSubscription _subscription; + + @override + void dispose() { + _subscription.cancel(); + super.dispose(); + } +} diff --git a/Dart/multi_counter/app/lib/src/screens/counter/screen.dart b/Dart/multi_counter/app/lib/src/screens/counter/screen.dart new file mode 100644 index 0000000000..97a5479db8 --- /dev/null +++ b/Dart/multi_counter/app/lib/src/screens/counter/screen.dart @@ -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 createState() => _CounterScreenState(); +} + +class _CounterScreenState extends State { + final state = CounterState(); + late final StreamSubscription _sub; + late final Listenable _merger; + + @override + void initState() { + super.initState(); + + _merger = Listenable.merge([state.userCounter, state.globalCounter]); + + ScaffoldFeatureController? + 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); diff --git a/Dart/multi_counter/app/lib/src/screens/counter/state.dart b/Dart/multi_counter/app/lib/src/screens/counter/state.dart new file mode 100644 index 0000000000..c5dc68a474 --- /dev/null +++ b/Dart/multi_counter/app/lib/src/screens/counter/state.dart @@ -0,0 +1,117 @@ +import 'dart:async'; + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:cloud_functions/cloud_functions.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter/foundation.dart'; +import 'package:multi_counter_shared/multi_counter_shared.dart'; +import 'package:stream_transform/stream_transform.dart'; + +import '../../config_state.dart'; + +typedef GlobalData = ({int totalUsers, int totalClicks}); + +class CounterState { + CounterState() { + _incrementController.stream + .switchMap((_) => _callIncrement().asStream()) + .listen(_handleIncrementResult); + + _initFirestore(); + } + + final ValueNotifier userCounter = ValueNotifier(0); + final ValueNotifier globalCounter = ValueNotifier(null); + + final _incrementController = StreamController.broadcast(); + final _subscriptions = []; + final _responseController = StreamController.broadcast(); + + Stream get incrementResponseStream => + _responseController.stream; + + // TODO: consider creating shared constants for collection and field names. + // ...and putting them in the shared package. + void _initFirestore() { + final uid = FirebaseAuth.instance.currentUser?.uid; + if (uid != null) { + _subscriptions.add( + FirebaseFirestore.instance + .collection(usersCollection) + .doc(uid) + .snapshots() + .listen((snapshot) { + if (snapshot.exists) { + final data = snapshot.data(); + if (data != null && data.containsKey(countField)) { + userCounter.value = data[countField] as int; + } + } + }), + ); + + _subscriptions.add( + FirebaseFirestore.instance + .collection(globalCollection) + .doc(varsDocument) + .snapshots() + .listen((snapshot) { + if (snapshot.data() case { + totalCountField: int totalClicks, + totalUsersField: int totalUsers, + }) { + globalCounter.value = ( + totalUsers: totalUsers, + totalClicks: totalClicks, + ); + } + }), + ); + } else { + print('no uid'); + } + } + + // TODO: consider making this a nullable-property and disabling + // the button when we're waiting for the function to complete. + void increment() { + _incrementController.add(null); + } + + Future _callIncrement() async { + final user = FirebaseAuth.instance.currentUser; + if (user == null) { + _responseController.add( + IncrementResponse.failure('User is not authenticated.'), + ); + return; + } + + final idToken = await user.getIdToken(); + if (idToken == null) { + _responseController.add( + IncrementResponse.failure('User is not authenticated.'), + ); + return; + } + + try { + await incrementHttpsCallable.call(); + } on FirebaseFunctionsException catch (e) { + print('Error calling increment: ${e.code} ${e.message}'); + _responseController.add(IncrementResponse.failure('Error: ${e.code}')); + } + } + + void _handleIncrementResult(_) { + // TODO: handle the result + } + + void dispose() { + _responseController.close(); + _incrementController.close(); + for (final sub in _subscriptions) { + sub.cancel(); + } + } +} diff --git a/Dart/multi_counter/app/lib/src/screens/login.dart b/Dart/multi_counter/app/lib/src/screens/login.dart new file mode 100644 index 0000000000..66dc714073 --- /dev/null +++ b/Dart/multi_counter/app/lib/src/screens/login.dart @@ -0,0 +1,67 @@ +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../constants.dart'; +import '../widgets/app_scaffold.dart'; + +class LoginScreen extends StatelessWidget { + const LoginScreen({super.key}); + + Future _signIn() async { + try { + final googleProvider = GoogleAuthProvider()..addScope('email'); + if (kDebugMode) { + await FirebaseAuth.instance.signInWithPopup(googleProvider); + } else { + await FirebaseAuth.instance.signInWithRedirect(googleProvider); + } + } catch (e) { + print('Google sign in error: $e'); + } + } + + @override + Widget build(BuildContext context) => AppScaffold( + useCardGradient: true, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + appTitle, + style: Theme.of(context).textTheme.headlineMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Sign in to continue', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: doubleSpaceSize), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _signIn, + icon: const Icon(Icons.login), + label: const Text('Sign in with Google'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: spaceSize), + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Theme.of(context).colorScheme.onPrimary, + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ), + ), + ); +} diff --git a/Dart/multi_counter/app/lib/src/widgets/about_popup.dart b/Dart/multi_counter/app/lib/src/widgets/about_popup.dart new file mode 100644 index 0000000000..4d4ab59795 --- /dev/null +++ b/Dart/multi_counter/app/lib/src/widgets/about_popup.dart @@ -0,0 +1,119 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../constants.dart'; + +class AboutPopup extends StatelessWidget { + const AboutPopup({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Dialog( + backgroundColor: Colors.transparent, + insetPadding: const EdgeInsets.all(spaceSize * 2), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: maxContentWidth), + child: Container( + padding: const EdgeInsets.all(doubleSpaceSize), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(28), + border: Border.all(color: colorScheme.outlineVariant), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 24, + offset: const Offset(0, 12), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon( + Icons.info_outline, + color: colorScheme.primary, + size: 28, + ), + const SizedBox(width: spaceSize), + Expanded( + child: Text( + 'About $appTitle', + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + tooltip: 'Close', + ), + ], + ), + const SizedBox(height: doubleSpaceSize), + Text( + 'This is a demo application showcasing the ' + 'integration of Flutter, Firebase, and Dart.', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: spaceSize), + Text.rich( + TextSpan( + children: [ + const TextSpan(text: 'Source Code: '), + TextSpan( + text: githubDisplayUrl, + style: TextStyle( + color: colorScheme.primary, + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () => launchUrl(githubUri), + ), + ], + ), + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: doubleSpaceSize), + Container( + padding: const EdgeInsets.all(spaceSize), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues( + alpha: 0.5, + ), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + Icon( + Icons.privacy_tip_outlined, + color: colorScheme.secondary, + ), + const SizedBox(width: spaceSize), + Expanded( + child: Text( + "Privacy: I won't do anything weird with your email. " + 'Promise.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/Dart/multi_counter/app/lib/src/widgets/app_scaffold.dart b/Dart/multi_counter/app/lib/src/widgets/app_scaffold.dart new file mode 100644 index 0000000000..a4a1ec44c1 --- /dev/null +++ b/Dart/multi_counter/app/lib/src/widgets/app_scaffold.dart @@ -0,0 +1,125 @@ +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter/material.dart'; + +import '../constants.dart'; +import 'about_popup.dart'; +import 'centered_premium_card.dart'; + +class AppScaffold extends StatelessWidget { + final Widget child; + final String? title; + final bool useCardGradient; + + const AppScaffold({ + super.key, + required this.child, + this.title, + this.useCardGradient = false, + }); + + @override + Widget build(BuildContext context) { + final user = FirebaseAuth.instance.currentUser; + final size = MediaQuery.of(context).size; + final isSmall = size.width < 370 || size.height < 650; + + if (isSmall) { + return _buildSmallLayout(context, user); + } + + return _buildLargeLayout(context, user); + } + + Widget _buildSmallLayout(BuildContext context, User? user) => Scaffold( + body: SafeArea( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: spaceSize / 2, + horizontal: spaceSize / 2, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Content + child, + + const SizedBox(height: doubleSpaceSize), + + // About + TextButton.icon( + onPressed: () => showDialog( + context: context, + builder: (context) => const AboutPopup(), + ), + icon: const Icon(Icons.info_outline), + label: const Text('About'), + ), + + // User info + if (user != null) ...[ + const SizedBox(height: spaceSize), + Text( + user.email ?? '', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: spaceSize / 2), + IconButton( + icon: const Icon(Icons.logout), + onPressed: () => FirebaseAuth.instance.signOut(), + ), + ], + ], + ), + ), + ), + ), + ); + + Widget _buildLargeLayout(BuildContext context, User? user) => Scaffold( + body: CenteredPremiumCard(useGradient: useCardGradient, child: child), + bottomNavigationBar: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: spaceSize, + vertical: spaceSize / 2, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextButton.icon( + onPressed: () => showDialog( + context: context, + builder: (context) => const AboutPopup(), + ), + icon: const Icon(Icons.info_outline), + label: const Text('About'), + ), + if (user != null) ...[ + const SizedBox(width: spaceSize), + Expanded( + child: Text( + user.email ?? '', + style: Theme.of(context).textTheme.bodyMedium, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + const SizedBox(width: spaceSize / 2), + IconButton( + icon: const Icon(Icons.logout), + onPressed: () => FirebaseAuth.instance.signOut(), + ), + ], + ], + ), + ], + ), + ), + ), + ); +} diff --git a/Dart/multi_counter/app/lib/src/widgets/centered_premium_card.dart b/Dart/multi_counter/app/lib/src/widgets/centered_premium_card.dart new file mode 100644 index 0000000000..4b67360fe7 --- /dev/null +++ b/Dart/multi_counter/app/lib/src/widgets/centered_premium_card.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; + +import '../constants.dart'; + +class CenteredPremiumCard extends StatelessWidget { + final Widget child; + final bool useGradient; + final EdgeInsetsGeometry padding; + final double? maxWidth; + + const CenteredPremiumCard({ + super.key, + required this.child, + this.useGradient = false, + this.padding = const EdgeInsets.all(doubleSpaceSize), + this.maxWidth, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Center( + child: Padding( + padding: const EdgeInsets.all(spaceSize), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth ?? maxContentWidth), + child: Container( + decoration: BoxDecoration( + color: useGradient ? null : colorScheme.surface, + gradient: useGradient + ? LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + colorScheme.surface, + colorScheme.surfaceContainerHighest.withValues( + alpha: 0.5, + ), + ], + ) + : null, + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Padding(padding: padding, child: child), + ), + ), + ), + ); + } +} diff --git a/Dart/multi_counter/app/pubspec.yaml b/Dart/multi_counter/app/pubspec.yaml new file mode 100644 index 0000000000..cc5746962c --- /dev/null +++ b/Dart/multi_counter/app/pubspec.yaml @@ -0,0 +1,30 @@ +name: multi_counter_app +publish_to: 'none' + +version: 1.0.0+1 +resolution: workspace + +environment: + sdk: ^3.10.0 + +dependencies: + cloud_firestore: ^6.1.2 + cloud_functions: ^6.0.0 + firebase_auth: ^6.1.4 + firebase_core: ^4.4.0 + flutter: + sdk: flutter + go_router: ^17.0.1 + google_sign_in: any + http: ^1.6.0 + multi_counter_shared: any + stream_transform: ^2.1.1 + url_launcher: ^6.3.2 + +dev_dependencies: + build_runner: + flutter_test: + sdk: flutter + +flutter: + uses-material-design: true diff --git a/Dart/multi_counter/app/web/favicon.png b/Dart/multi_counter/app/web/favicon.png new file mode 100644 index 0000000000..8aaa46ac1a Binary files /dev/null and b/Dart/multi_counter/app/web/favicon.png differ diff --git a/Dart/multi_counter/app/web/icons/Icon-192.png b/Dart/multi_counter/app/web/icons/Icon-192.png new file mode 100644 index 0000000000..b749bfef07 Binary files /dev/null and b/Dart/multi_counter/app/web/icons/Icon-192.png differ diff --git a/Dart/multi_counter/app/web/icons/Icon-512.png b/Dart/multi_counter/app/web/icons/Icon-512.png new file mode 100644 index 0000000000..88cfd48dff Binary files /dev/null and b/Dart/multi_counter/app/web/icons/Icon-512.png differ diff --git a/Dart/multi_counter/app/web/icons/Icon-maskable-192.png b/Dart/multi_counter/app/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000000..eb9b4d76e5 Binary files /dev/null and b/Dart/multi_counter/app/web/icons/Icon-maskable-192.png differ diff --git a/Dart/multi_counter/app/web/icons/Icon-maskable-512.png b/Dart/multi_counter/app/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000000..d69c56691f Binary files /dev/null and b/Dart/multi_counter/app/web/icons/Icon-maskable-512.png differ diff --git a/Dart/multi_counter/app/web/index.html b/Dart/multi_counter/app/web/index.html new file mode 100644 index 0000000000..03232de553 --- /dev/null +++ b/Dart/multi_counter/app/web/index.html @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + Multi-counter! + + + + + diff --git a/Dart/multi_counter/firebase.json b/Dart/multi_counter/firebase.json new file mode 100644 index 0000000000..ccc9fe0522 --- /dev/null +++ b/Dart/multi_counter/firebase.json @@ -0,0 +1,71 @@ +{ + "firestore": { + "rules": "server/firestore.rules" + }, + "functions": [ + { + "source": "server", + "codebase": "default", + "runtime": "dart3" + } + ], + "hosting": { + "predeploy": "cd app && flutter build web --wasm --no-strip-wasm --source-maps", + "public": "app/build/web", + "ignore": [ + "firebase.json", + "**/.*" + ], + "rewrites": [ + { + "source": "**", + "destination": "/index.html" + } + ], + "headers": [ + { + "source": "**", + "headers": [ + { + "key": "Cross-Origin-Opener-Policy", + "value": "same-origin-allow-popups" + } + ] + } + ] + }, + "emulators": { + "functions": { + "port": 5001 + }, + "auth": { + "port": 9099 + }, + "pubsub": { + "port": 8085 + }, + "firestore": { + "port": 8080 + }, + "database": { + "port": 9000 + }, + "ui": { + "enabled": true, + "port": 4000 + }, + "singleProjectMode": true + }, + "flutter": { + "platforms": { + "dart": { + "app/lib/firebase_options.dart": { + "projectId": "n26-full-stack-dart", + "configurations": { + "web": "1:138342796561:web:22e80ba7c9119d66d950b0" + } + } + } + } + } +} diff --git a/Dart/multi_counter/pubspec.lock b/Dart/multi_counter/pubspec.lock new file mode 100644 index 0000000000..6a0e7e7184 --- /dev/null +++ b/Dart/multi_counter/pubspec.lock @@ -0,0 +1,930 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _discoveryapis_commons: + dependency: transitive + description: + name: _discoveryapis_commons + sha256: "113c4100b90a5b70a983541782431b82168b3cae166ab130649c36eb3559d498" + url: "https://pub.dev" + source: hosted + version: "1.0.7" + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" + url: "https://pub.dev" + source: hosted + version: "93.0.0" + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: bda3b7b55958bfd867addc40d067b4b11f7b8846d57671f5b5a6e7f9a56fe3ad + url: "https://pub.dev" + source: hosted + version: "1.3.69" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b + url: "https://pub.dev" + source: hosted + version: "10.0.1" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + asn1lib: + dependency: transitive + description: + name: asn1lib + sha256: "9a8f69025044eb466b9b60ef3bc3ac99b4dc6c158ae9c56d25eeccf5bc56d024" + url: "https://pub.dev" + source: hosted + version: "1.6.5" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: aadd943f4f8cc946882c954c187e6115a84c98c81ad1d9c6cbf0895a8c85da9c + url: "https://pub.dev" + source: hosted + version: "4.0.5" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + url: "https://pub.dev" + source: hosted + version: "4.1.1" + build_runner: + dependency: transitive + description: + name: build_runner + sha256: "521daf8d189deb79ba474e43a696b41c49fb3987818dbacf3308f1e03673a75e" + url: "https://pub.dev" + source: hosted + version: "2.13.1" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af" + url: "https://pub.dev" + source: hosted + version: "8.12.5" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + cloud_firestore: + dependency: transitive + description: + name: cloud_firestore + sha256: "3ac242332166ae5037bd87bc343744bb96d88d7b13f791492b00958ce5cc6c63" + url: "https://pub.dev" + source: hosted + version: "6.3.0" + cloud_firestore_platform_interface: + dependency: transitive + description: + name: cloud_firestore_platform_interface + sha256: "1bd08b736e1015e8bf5448f5ef67b2087a2380c2c1c7972f8403c1c7b41f5359" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + cloud_firestore_web: + dependency: transitive + description: + name: cloud_firestore_web + sha256: "18617275ffa2331d3ea058c515ef218bcce2ae13a14bee922563ca6ae2507c26" + url: "https://pub.dev" + source: hosted + version: "5.3.0" + cloud_functions: + dependency: transitive + description: + name: cloud_functions + sha256: "036aa4f3b880935bda48fd6ab516e0f11686c328a46313226b9cbad9220b4c59" + url: "https://pub.dev" + source: hosted + version: "6.2.0" + cloud_functions_platform_interface: + dependency: transitive + description: + name: cloud_functions_platform_interface + sha256: "2a52ee909011b0f33ae2074b7a431bc2d7fff4618948d93d5e71830688e0733f" + url: "https://pub.dev" + source: hosted + version: "5.8.12" + cloud_functions_web: + dependency: transitive + description: + name: cloud_functions_web + sha256: be032545ca1621248ffd251930952835593a9b8136895379930cecea766379a4 + url: "https://pub.dev" + source: hosted + version: "5.1.5" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + url: "https://pub.dev" + source: hosted + version: "4.11.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + dart_flutter_team_lints: + dependency: "direct dev" + description: + name: dart_flutter_team_lints + sha256: ce0f23e2cf95cbd21766d17a7cf88584758b67fd77338d61f2ce77e3cf6d763c + url: "https://pub.dev" + source: hosted + version: "3.5.2" + dart_jsonwebtoken: + dependency: transitive + description: + name: dart_jsonwebtoken + sha256: cb79ed79baa02b4f59a597bf365873cbd83f9bb15273d63f7803802d21717c7d + url: "https://pub.dev" + source: hosted + version: "3.4.0" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2" + url: "https://pub.dev" + source: hosted + version: "3.1.7" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + url: "https://pub.dev" + source: hosted + version: "2.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + firebase_admin_sdk: + dependency: transitive + description: + name: firebase_admin_sdk + sha256: "3827a3b30f3e76e9502cafab6fb6be28b29c632e83475b6ce3f146b5e077baad" + url: "https://pub.dev" + source: hosted + version: "0.5.1" + firebase_auth: + dependency: transitive + description: + name: firebase_auth + sha256: b12cb1e2e87797d27e0041100b73ebf890dbafcff2e7e991d4593f5e8e309808 + url: "https://pub.dev" + source: hosted + version: "6.4.0" + firebase_auth_platform_interface: + dependency: transitive + description: + name: firebase_auth_platform_interface + sha256: c71517b3c78480be42789b05316a7692d69296c17848bd6a9e798300abae1ec7 + url: "https://pub.dev" + source: hosted + version: "8.1.9" + firebase_auth_web: + dependency: transitive + description: + name: firebase_auth_web + sha256: "52b0224eb46b09f387e99710707be2d3f48da67c74fe14202e4b942cbe8ce9fd" + url: "https://pub.dev" + source: hosted + version: "6.1.5" + firebase_core: + dependency: transitive + description: + name: firebase_core + sha256: d5a94b884dcb1e6d3430298e94bfe002238094cdfd5e29202d536ee2120f9158 + url: "https://pub.dev" + source: hosted + version: "4.7.0" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + sha256: "0ecda14c1bfc9ed8cac303dd0f8d04a320811b479362a9a4efb14fd331a473ce" + url: "https://pub.dev" + source: hosted + version: "6.0.3" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + sha256: dc5096257cd67292d34d78ceeb90836f02a4be921b5f3934311a02bb2376118c + url: "https://pub.dev" + source: hosted + version: "3.6.0" + firebase_functions: + dependency: transitive + description: + name: firebase_functions + sha256: c4e9cdcf8a5650223b20992be52517981f277c4e6d9a17c1077c9523a4d65e8f + url: "https://pub.dev" + source: hosted + version: "0.5.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_test: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + go_router: + dependency: transitive + description: + name: go_router + sha256: "5540e4a3f416dd4a93458257b908eb88353cbd0fb5b0a3d1bd7d849ba1e88735" + url: "https://pub.dev" + source: hosted + version: "17.2.1" + google_cloud: + dependency: transitive + description: + name: google_cloud + sha256: fbcde933b2d8600c3cdb2328f8f4c47628ec29a39e9cef85dee535c7868993c4 + url: "https://pub.dev" + source: hosted + version: "0.4.1" + google_cloud_firestore: + dependency: transitive + description: + name: google_cloud_firestore + sha256: e9a788301ee25cef6ffa86046cb0e80b4974b4a72d23a25231e161587fd84c8c + url: "https://pub.dev" + source: hosted + version: "0.5.1" + google_cloud_protobuf: + dependency: transitive + description: + name: google_cloud_protobuf + sha256: "5564bfba335f7ca82fcc865f6f2c04b11fd175ec23cb0248f6e22579017e03d1" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + google_cloud_rpc: + dependency: transitive + description: + name: google_cloud_rpc + sha256: "7eef0b8a77021d19aff96cefcb9356dde4187daac3ecec33d7017637b83cdb4d" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + google_cloud_storage: + dependency: transitive + description: + name: google_cloud_storage + sha256: "8410cddc2c37b7ef81d154b3533a7d8cf22a175f519fe9ba4ee0d5a790ea3040" + url: "https://pub.dev" + source: hosted + version: "0.6.1" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" + url: "https://pub.dev" + source: hosted + version: "0.3.3+1" + google_sign_in: + dependency: transitive + description: + name: google_sign_in + sha256: "521031b65853b4409b8213c0387d57edaad7e2a949ce6dea0d8b2afc9cb29763" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + google_sign_in_android: + dependency: transitive + description: + name: google_sign_in_android + sha256: be0d0733a6a7c5da165879d844a239aa87587a3c767a9163faedde581f731f76 + url: "https://pub.dev" + source: hosted + version: "7.2.10" + google_sign_in_ios: + dependency: transitive + description: + name: google_sign_in_ios + sha256: ac1e4c1205267cb7999d1d81333fccffdfda29e853f434bbaf71525498bb6950 + url: "https://pub.dev" + source: hosted + version: "6.3.0" + google_sign_in_platform_interface: + dependency: transitive + description: + name: google_sign_in_platform_interface + sha256: "7f59208c42b415a3cca203571128d6f84f885fead2d5b53eb65a9e27f2965bb5" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + google_sign_in_web: + dependency: transitive + description: + name: google_sign_in_web + sha256: d473003eeca892f96a01a64fc803378be765071cb0c265ee872c7f8683245d14 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + googleapis: + dependency: transitive + description: + name: googleapis + sha256: "62b5988f228b774448ebd99b53fe8069becb55840f1b28948bb84160373dc0b6" + url: "https://pub.dev" + source: hosted + version: "16.0.0" + googleapis_auth: + dependency: transitive + description: + name: googleapis_auth + sha256: "661738b763d3e524de69df53bf4e03943e4e01e98265cebcc6684871b06a5379" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 + url: "https://pub.dev" + source: hosted + version: "4.11.0" + json_serializable: + dependency: transitive + description: + name: json_serializable + sha256: fbcf404b03520e6e795f6b9b39badb2b788407dfc0a50cf39158a6ae1ca78925 + url: "https://pub.dev" + source: hosted + version: "6.13.1" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pem: + dependency: transitive + description: + name: pem + sha256: e66b389cbb007fa5860d511f08ea604ea68e78afc4e1b543dc227a0f0ef4faf9 + url: "https://pub.dev" + source: hosted + version: "2.0.6" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + protobuf: + dependency: transitive + description: + name: protobuf + sha256: "75ec242d22e950bdcc79ee38dd520ce4ee0bc491d7fadc4ea47694604d22bf06" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "732792cfd197d2161a65bb029606a46e0a18ff30ef9e141a7a82172b05ea8ecd" + url: "https://pub.dev" + source: hosted + version: "4.2.2" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "1d3b229b2934034fb2e691fbb3d53e0f75a4af7b1407f88425ed8f209bcb1b8f" + url: "https://pub.dev" + source: hosted + version: "1.3.11" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: transitive + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572" + url: "https://pub.dev" + source: hosted + version: "6.3.29" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f + url: "https://pub.dev" + source: hosted + version: "2.4.2" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "046d3928e16fa4dc46e8350415661755ab759d9fc97fc21b5ab295f71e4f0499" + url: "https://pub.dev" + source: hosted + version: "15.1.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" + yaml_edit: + dependency: transitive + description: + name: yaml_edit + sha256: "07c9e63ba42519745182b88ca12264a7ba2484d8239958778dfe4d44fe760488" + url: "https://pub.dev" + source: hosted + version: "2.2.4" +sdks: + dart: ">=3.10.0 <4.0.0" + flutter: ">=3.38.0" diff --git a/Dart/multi_counter/pubspec.yaml b/Dart/multi_counter/pubspec.yaml new file mode 100644 index 0000000000..c1ca6d2797 --- /dev/null +++ b/Dart/multi_counter/pubspec.yaml @@ -0,0 +1,12 @@ +name: _multi_counter_workspace + +environment: + sdk: ^3.10.0 + +workspace: +- app +- server +- shared + +dev_dependencies: + dart_flutter_team_lints: ^3.0.0 diff --git a/Dart/multi_counter/server/bin/server.dart b/Dart/multi_counter/server/bin/server.dart new file mode 100644 index 0000000000..ca10d44a53 --- /dev/null +++ b/Dart/multi_counter/server/bin/server.dart @@ -0,0 +1,26 @@ +import 'package:firebase_functions/firebase_functions.dart'; +import 'package:multi_counter_server/src/storage_controller.dart'; +import 'package:multi_counter_shared/multi_counter_shared.dart'; + +void main(List args) async { + await fireUp(args, (firebase) async { + final storageController = StorageController(firebase.adminApp.firestore()); + + firebase.https.onCall( + name: incrementCallable, + + options: const CallableOptions( + // TODO: should be explicit here about the supported hosts + cors: OptionLiteral(['*']), + ), + (request, response) async { + if (request.auth case AuthData auth?) { + await storageController.increment(auth.uid); + return CallableResult('success'); + } else { + throw UnauthenticatedError(); + } + }, + ); + }); +} diff --git a/Dart/multi_counter/server/firestore.rules b/Dart/multi_counter/server/firestore.rules new file mode 100644 index 0000000000..34b40a3088 --- /dev/null +++ b/Dart/multi_counter/server/firestore.rules @@ -0,0 +1,31 @@ +rules_version = '2'; + +service cloud.firestore { + match /databases/{database}/documents { + // Rules for the 'users' collection + // Hierarchy: users (collection) -> {userId} (document) -> count (field) + match /users/{userId} { + // Users can only read their own document + allow read: if request.auth != null && request.auth.uid == userId; + + // Writes are handled by the backend (Cloud Functions) + // which bypass these rules via service account privileges. + allow write: if false; + } + + // Rules for the 'global' collection + // Hierarchy: global (collection) -> vars (document) -> totalCount, totalUsers (fields) + match /global/vars { + // Any authenticated user can read global stats + allow read: if request.auth != null; + + // Writes are handled by the backend + allow write: if false; + } + + // Default deny all for any other paths + match /{path=**} { + allow read, write: if false; + } + } +} diff --git a/Dart/multi_counter/server/lib/src/storage_controller.dart b/Dart/multi_counter/server/lib/src/storage_controller.dart new file mode 100644 index 0000000000..f547ff6895 --- /dev/null +++ b/Dart/multi_counter/server/lib/src/storage_controller.dart @@ -0,0 +1,77 @@ +import 'package:google_cloud_firestore/google_cloud_firestore.dart'; +import 'package:multi_counter_shared/multi_counter_shared.dart'; + +class StorageController { + final Firestore _firestore; + + StorageController(this._firestore); + + Future increment(String userId) async { + try { + await _increment(userId); + await _updateGlobalCount(); + } catch (e, stack) { + print('Error incrementing counter for user: $userId'); + print(e); + print(stack); + rethrow; + } + } + + Future _increment(String userId) async { + await _firestore.runTransaction((transaction) async { + final ref = _firestore.collection(usersCollection).doc(userId); + + final snapshot = await transaction.get(ref); + + if (!snapshot.exists) { + // Document doesn't exist, create it with count = 1 + transaction.set(ref, _saveCount(1)); + } else { + final data = snapshot.data(); + if (data != null && data.containsKey(countField)) { + // Field exists, increment it + transaction.update(ref, {countField: const FieldValue.increment(1)}); + } else { + // Field doesn't exist, initialize it to 1 + transaction.update(ref, _saveCount(1)); + } + } + }); + } + + Future _updateGlobalCount() async { + final globalCountSnapshot = await _firestore + .collection(usersCollection) + .aggregate(const sum(countField), const count()) + .get(); + + var globalCountRaw = globalCountSnapshot.getSum(countField); + + if (globalCountRaw == null || globalCountRaw < 1) { + // TODO: we don't want to crash here, but we should log + print('Very weird value for global count: "$globalCountRaw'); + globalCountRaw = 1; + } + + final globalCountValue = globalCountRaw.toInt(); + final userCountValue = globalCountSnapshot.count; + + final globalVars = _firestore + .collection(globalCollection) + .doc(varsDocument); + + // TODO: Investigate a more efficient way to do this + // Maybe with a trigger? + await globalVars.set({ + totalCountField: globalCountValue, + totalUsersField: userCountValue, + }); + } + + Future close() async { + await _firestore.terminate(); + } +} + +Map _saveCount(int count) => {countField: count}; diff --git a/Dart/multi_counter/server/pubspec.yaml b/Dart/multi_counter/server/pubspec.yaml new file mode 100644 index 0000000000..985f5e37c8 --- /dev/null +++ b/Dart/multi_counter/server/pubspec.yaml @@ -0,0 +1,16 @@ +name: multi_counter_server +publish_to: none +resolution: workspace + +environment: + sdk: ^3.10.0 + +dependencies: + firebase_admin_sdk: ^0.5.0 + firebase_functions: ^0.5.0 + google_cloud_firestore: ^0.5.0 + multi_counter_shared: any # workspace + +dev_dependencies: + build_runner: ^2.4.0 + http: ^1.2.2 diff --git a/Dart/multi_counter/shared/README.md b/Dart/multi_counter/shared/README.md new file mode 100644 index 0000000000..5249489be0 --- /dev/null +++ b/Dart/multi_counter/shared/README.md @@ -0,0 +1,37 @@ +# Firestore layout + +``` +users/{userId} + count: Integer + +global/vars + totalCount: Integer +``` + +**TODO**: Put these in a shared Dart file to ensure they stay in sync with the code. + + + +## Security Rules + + +**TODO**: Implement/deploy these rules! + +``` +rules_version = '2'; +service cloud.firestore { + match /databases/{database}/documents { + // Allow users to read/write their own data + match /users/{userId} { + allow read, write: if request.auth != null && request.auth.uid == userId; + } + + // Allow authenticated users to only read global data + match /global/{docId} { + allow read: if request.auth != null; + allow write: if false; + } + } +} +``` + diff --git a/Dart/multi_counter/shared/lib/multi_counter_shared.dart b/Dart/multi_counter/shared/lib/multi_counter_shared.dart new file mode 100644 index 0000000000..551347e1a2 --- /dev/null +++ b/Dart/multi_counter/shared/lib/multi_counter_shared.dart @@ -0,0 +1,2 @@ +export 'src/constants.dart'; +export 'src/messages.dart'; diff --git a/Dart/multi_counter/shared/lib/src/constants.dart b/Dart/multi_counter/shared/lib/src/constants.dart new file mode 100644 index 0000000000..9d6bb4aa06 --- /dev/null +++ b/Dart/multi_counter/shared/lib/src/constants.dart @@ -0,0 +1,17 @@ +/// Firestore constants for the `users` collection. +/// +/// Hierarchy: `users` (collection) -> {userId} (document) -> `count` (field) +const usersCollection = 'users'; +const countField = 'count'; + +/// Firestore constants for the `global` collection. +/// +/// Hierarchy: `global` (collection) -> `vars` (document) -> +/// `totalCount`, `totalUsers` (fields) +const globalCollection = 'global'; +const varsDocument = 'vars'; +const totalCountField = 'totalCount'; +const totalUsersField = 'totalUsers'; + +/// HTTPS Callable function names. +const incrementCallable = 'increment'; diff --git a/Dart/multi_counter/shared/lib/src/messages.dart b/Dart/multi_counter/shared/lib/src/messages.dart new file mode 100644 index 0000000000..6011a30e7b --- /dev/null +++ b/Dart/multi_counter/shared/lib/src/messages.dart @@ -0,0 +1,21 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'messages.g.dart'; + +@JsonSerializable() +class IncrementResponse { + final bool success; + final String? message; + + const IncrementResponse({required this.success, this.message}); + + factory IncrementResponse.success() => const IncrementResponse(success: true); + + factory IncrementResponse.failure(String message) => + IncrementResponse(success: false, message: message); + + factory IncrementResponse.fromJson(Map json) => + _$IncrementResponseFromJson(json); + + Map toJson() => _$IncrementResponseToJson(this); +} diff --git a/Dart/multi_counter/shared/lib/src/messages.g.dart b/Dart/multi_counter/shared/lib/src/messages.g.dart new file mode 100644 index 0000000000..4b9c46c922 --- /dev/null +++ b/Dart/multi_counter/shared/lib/src/messages.g.dart @@ -0,0 +1,16 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'messages.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +IncrementResponse _$IncrementResponseFromJson(Map json) => + IncrementResponse( + success: json['success'] as bool, + message: json['message'] as String?, + ); + +Map _$IncrementResponseToJson(IncrementResponse instance) => + {'success': instance.success, 'message': instance.message}; diff --git a/Dart/multi_counter/shared/pubspec.yaml b/Dart/multi_counter/shared/pubspec.yaml new file mode 100644 index 0000000000..9b25a6fb47 --- /dev/null +++ b/Dart/multi_counter/shared/pubspec.yaml @@ -0,0 +1,13 @@ +name: multi_counter_shared +publish_to: none +resolution: workspace + +environment: + sdk: ^3.10.0 + +dependencies: + json_annotation: ^4.11.0 + +dev_dependencies: + build_runner: ^2.10.5 + json_serializable: ^6.12.0 diff --git a/README.md b/README.md index 9ecbdf405d..6b6fadc66c 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,12 @@ To learn how to get started with Cloud Functions for Firebase by having a look a Minimal samples for each Cloud Functions trigger type. +### Multi-counter + +- [Dart](/Dart/multi_counter/) + +This sample contains a Flutter app, a shared Dart package, and Dart server-side Cloud Functions showcasing how to build a multi-counter application, as featured in a Cloud Next 2026 talk. + ### Quickstart: Uppercaser for Firestore - [Node 2nd gen](/Node/quickstarts/uppercase-firestore/)