diff --git a/lib/app.dart b/lib/app.dart index 40be51f..1f4aa17 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:animated_theme_switcher/animated_theme_switcher.dart';s +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:animated_theme_switcher/animated_theme_switcher.dart'; +import 'router/router.dart'; -class VeilidChatApp extends StatelessWidget { +class VeilidChatApp extends ConsumerWidget { const VeilidChatApp({ Key? key, required this.theme, @@ -10,14 +12,16 @@ class VeilidChatApp extends StatelessWidget { final ThemeData theme; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final router = ref.watch(routerProvider); + return ThemeProvider( initTheme: theme, builder: (_, theme) { - return MaterialApp( + return MaterialApp.router( + routerConfig: router, title: 'VeilidChat', theme: theme, - home: const MyHomePage(title: 'Flutter Demo Home Page'), ); }, ); diff --git a/lib/entities/contact.dart b/lib/entities/contact.dart new file mode 100644 index 0000000..98d03e0 --- /dev/null +++ b/lib/entities/contact.dart @@ -0,0 +1,96 @@ + +class Contact { + String name; + String publicKey; + bool available; + + Contact(this.name, this.publicKey) : available = false; + + Contact.fromJson(Map json) + : name = json['name'], + publicKey = json['public_key'], + available = json['available']; + + Map toJson() { + return { + 'name': name, + 'public_key': publicKey, + 'available': available, + }; + } +} + +// // Synchronize the app state contact list with the internal one +// void sortAndStoreAppStateContactList(List ffasContactList) { +// ffasContactList.sortBy((element) => element["name"] as String); +// FFAppState().update(() { +// FFAppState().ContactList = ffasContactList; +// }); +// } + +// // Called when a new contact is added or an existing one is edited +// Future vcsUpdateContact( +// String id, +// String name, +// String publicKey, +// ) async { +// var api = Veilid.instance; +// try { +// // if we are adding a new contact, make its id +// var newContact = false; +// if (id.isEmpty) { +// id = const Uuid().v4(); +// newContact = true; +// } + +// // Trim name +// name = name.trim(); + +// // Validate name and public key +// if (name.length > 127) { +// return "Name is too long."; +// } +// if (name.isEmpty) { +// return "Name can not be empty"; +// } +// if (!isValidDHTKey(publicKey)) { +// return "Public key is not valid"; +// } + +// // update entry in internal contacts table +// var contactsDb = await api.openTableDB("contacts", 1); +// var contact = Contact(name, publicKey); +// await contactsDb.storeStringJson(0, id, contact); + +// // update app state +// var contactJson = contact.toJson(); +// contactJson['id'] = id; + +// var ffasContactList = FFAppState().ContactList; +// if (newContact) { +// // Add new contact +// ffasContactList.add(contactJson); +// } else { +// // Update existing contact +// ffasContactList.forEachIndexedWhile((i, e) { +// if (e['id'] == id) { +// ffasContactList[i] = contactJson; +// return false; +// } +// return true; +// }); +// } + +// // Sort the contact list +// sortAndStoreAppStateContactList(ffasContactList); +// } catch (e) { +// return e.toString(); +// } + +// return ""; +// } + +// // Called when a contact is to be removed +// Future vcsDeleteContact(String id) async { +// // +// } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index c8633ab..b33c4c1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,7 +17,7 @@ void main() async { var initTheme = themeService.initial; runApp( ProviderScope( - observers: [const StateLogger()], + observers: const [StateLogger()], child: VeilidChatApp(theme: initTheme)), ); } diff --git a/lib/pages/chat.dart b/lib/pages/chat.dart new file mode 100644 index 0000000..15eac48 --- /dev/null +++ b/lib/pages/chat.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class ChatPage extends ConsumerWidget { + const ChatPage({super.key}); + static const path = '/chat'; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + appBar: AppBar(title: const Text("Chat")), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Text("Home Page"), + ElevatedButton( + onPressed: () { + ref.watch(authNotifierProvider.notifier).logout(); + }, + child: const Text("Logout"), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/contacts.dart b/lib/pages/contacts.dart new file mode 100644 index 0000000..1d5652d --- /dev/null +++ b/lib/pages/contacts.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class ContactsPage extends ConsumerWidget { + const ContactsPage({super.key}); + static const path = '/contacts'; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + appBar: null, + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Text("Contacts Page"), + // ElevatedButton( + // onPressed: () async { + // ref.watch(authNotifierProvider.notifier).login( + // "myEmail", + // "myPassword", + // ); + // }, + // child: const Text("Login"), + // ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/edit_account.dart b/lib/pages/edit_account.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/pages/edit_contact.dart b/lib/pages/edit_contact.dart new file mode 100644 index 0000000..1d5652d --- /dev/null +++ b/lib/pages/edit_contact.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class ContactsPage extends ConsumerWidget { + const ContactsPage({super.key}); + static const path = '/contacts'; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + appBar: null, + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Text("Contacts Page"), + // ElevatedButton( + // onPressed: () async { + // ref.watch(authNotifierProvider.notifier).login( + // "myEmail", + // "myPassword", + // ); + // }, + // child: const Text("Login"), + // ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/home.dart b/lib/pages/home.dart index 594b3de..a4c2793 100644 --- a/lib/pages/home.dart +++ b/lib/pages/home.dart @@ -1,48 +1,29 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); - - final String title; +class HomePage extends ConsumerWidget { + const HomePage({super.key}); + static const path = '/home'; @override - State createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - int _counter = 0; - - void _incrementCounter() { - setState(() { - _counter++; - }); - } - - @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { return Scaffold( - appBar: AppBar( - title: Text(widget.title), - ), + appBar: AppBar(title: const Text("VeilidChat")), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text( - 'You have pushed the button this many times:', - ), - Text( - '$_counter', - style: Theme.of(context).textTheme.headline4, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Text("Home Page"), + ElevatedButton( + onPressed: () { + ref.watch(authNotifierProvider.notifier).logout(); + }, + child: const Text("Logout"), ), ], ), ), - floatingActionButton: FloatingActionButton( - onPressed: _incrementCounter, - tooltip: 'Increment', - child: const Icon(Icons.add), - ), ); } } diff --git a/lib/pages/index.dart b/lib/pages/index.dart new file mode 100644 index 0000000..51b67de --- /dev/null +++ b/lib/pages/index.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +class IndexPage extends StatelessWidget { + const IndexPage({super.key}); + static const path = '/'; + + @override + Widget build(BuildContext context) { + return const Scaffold( + body: Center(child: Text("Index Page")), + ); + } +} diff --git a/lib/pages/login.dart b/lib/pages/login.dart new file mode 100644 index 0000000..912acf8 --- /dev/null +++ b/lib/pages/login.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class LoginPage extends ConsumerWidget { + const LoginPage({super.key}); + static const path = '/login'; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + appBar: null, + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Text("Login Page"), + ElevatedButton( + onPressed: () async { + ref.watch(authNotifierProvider.notifier).login( + "myEmail", + "myPassword", + ); + }, + child: const Text("Login"), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/new_account.dart b/lib/pages/new_account.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/pages/pages.dart b/lib/pages/pages.dart new file mode 100644 index 0000000..c7ffc57 --- /dev/null +++ b/lib/pages/pages.dart @@ -0,0 +1,2 @@ +export 'home.dart'; +export 'splash.dart'; diff --git a/lib/pages/settings.dart b/lib/pages/settings.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/router/router.dart b/lib/router/router.dart new file mode 100644 index 0000000..6957b7f --- /dev/null +++ b/lib/router/router.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'router_notifier.dart'; + +final _key = GlobalKey(debugLabel: 'routerKey'); + +/// This simple provider caches our GoRouter. +final routerProvider = Provider.autoDispose((ref) { + final notifier = ref.watch(routerNotifierProvider.notifier); + + return GoRouter( + navigatorKey: _key, + refreshListenable: notifier, + debugLogDiagnostics: true, + initialLocation: SplashPage.path, + routes: notifier.routes, + redirect: notifier.redirect, + ); +}); diff --git a/lib/router/router_notifier.dart b/lib/router/router_notifier.dart new file mode 100644 index 0000000..fb203d0 --- /dev/null +++ b/lib/router/router_notifier.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../entities/user_role.dart'; +import '../pages/pages.dart'; +import '../state/auth.dart'; +import '../state/permissions.dart'; + +/// This notifier is meant to implement the [Listenable] our [GoRouter] needs. +/// +/// We aim to trigger redirects whenever's needed. +/// This is done by calling our (only) listener everytime we want to notify stuff. +/// This allows to centralize global redirecting logic in this class. +/// In this simple case, we just listen to auth changes. +/// +/// SIDE NOTE. +/// This might look overcomplicated at a first glance; +/// Instead, this method aims to follow some good some good practices: +/// 1. It doesn't require us to pipe down any `ref` parameter +/// 2. It works as a complete replacement for [ChangeNotifier] (it's a [Listenable] implementation) +/// 3. It allows for listening to multiple providers if needed (we do have a [Ref] now!) +class RouterNotifier extends AutoDisposeAsyncNotifier + implements Listenable { + VoidCallback? routerListener; + bool isAuth = false; // Useful for our global redirect functio + + @override + Future build() async { + // One could watch more providers and write logic accordingly + + isAuth = await ref.watch( + authNotifierProvider.selectAsync((data) => data != null), + ); + + ref.listenSelf((_, __) { + // One could write more conditional logic for when to call redirection + if (state.isLoading) return; + routerListener?.call(); + }); + } + + /// Redirects the user when our authentication changes + String? redirect(BuildContext context, GoRouterState state) { + if (this.state.isLoading || this.state.hasError) return null; + + final isSplash = state.location == IndexPage.path; + + if (isSplash) { + return isAuth ? HomePage.path : LoginPage.path; + } + + final isLoggingIn = state.location == LoginPage.path; + if (isLoggingIn) return isAuth ? HomePage.path : null; + + return isAuth ? null : IndexPage.path; + } + + /// Our application routes. Obtained through code generation + List get routes => [ + GoRoute( + path: IndexPage.path, + builder: (context, state) => const IndexPage(), + ), + GoRoute( + path: HomePage.path, + builder: (context, state) => const HomePage(), + redirect: (context, state) async { + if (state.location == HomePage.path) return null; + + final roleListener = ProviderScope.containerOf(context).listen( + permissionsProvider.select((value) => value.valueOrNull), + (previous, next) {}, + ); + + final userRole = roleListener.read(); + final redirectTo = userRole?.redirectBasedOn(state.location); + + roleListener.close(); + return redirectTo; + }, + routes: [ + GoRoute( + path: AdminPage.path, + builder: (context, state) => const AdminPage(), + ), + GoRoute( + path: UserPage.path, + builder: (context, state) => const UserPage(), + ), + GoRoute( + path: GuestPage.path, + builder: (context, state) => const GuestPage(), + ) + ]), + GoRoute( + path: LoginPage.path, + builder: (context, state) => const LoginPage(), + ), + ]; + + /// Adds [GoRouter]'s listener as specified by its [Listenable]. + /// [GoRouteInformationProvider] uses this method on creation to handle its + /// internal [ChangeNotifier]. + /// Check out the internal implementation of [GoRouter] and + /// [GoRouteInformationProvider] to see this in action. + @override + void addListener(VoidCallback listener) { + routerListener = listener; + } + + /// Removes [GoRouter]'s listener as specified by its [Listenable]. + /// [GoRouteInformationProvider] uses this method when disposing, + /// so that it removes its callback when destroyed. + /// Check out the internal implementation of [GoRouter] and + /// [GoRouteInformationProvider] to see this in action. + @override + void removeListener(VoidCallback listener) { + routerListener = null; + } +} + +final routerNotifierProvider = + AutoDisposeAsyncNotifierProvider(() { + return RouterNotifier(); +}); + +/// A simple extension to determine wherever should we redirect our users +extension RedirecttionBasedOnRole on UserRole { + /// Redirects the users based on [this] and its current [location] + String? redirectBasedOn(String location) { + switch (this) { + case UserRole.admin: + return null; + case UserRole.verifiedUser: + case UserRole.unverifiedUser: + if (location == AdminPage.path) return HomePage.path; + return null; + case UserRole.guest: + case UserRole.none: + if (location != HomePage.path) return HomePage.path; + return null; + } + } +} diff --git a/lib/state/connection_state.dart b/lib/state/connection_state.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/veilid_support/config.dart b/lib/veilid_support/config.dart new file mode 100644 index 0000000..1f74018 --- /dev/null +++ b/lib/veilid_support/config.dart @@ -0,0 +1,6 @@ +import 'package:veilid/veilid.dart'; + +Future getVeilidChatConfig() async { + VeilidConfig config = await getDefaultVeilidConfig("VeilidChat"); + return config; +} diff --git a/lib/veilid_support/init.dart b/lib/veilid_support/init.dart new file mode 100644 index 0000000..4e75191 --- /dev/null +++ b/lib/veilid_support/init.dart @@ -0,0 +1,63 @@ +import 'package:veilid/veilid.dart'; +import 'package:flutter/foundation.dart'; +import 'processor.dart'; + +Future getVeilidVersion() async { + String veilidVersion; + try { + veilidVersion = Veilid.instance.veilidVersionString(); + } on Exception { + veilidVersion = 'Failed to get veilid version.'; + } + return veilidVersion; +} + +// Initialize Veilid +// Call only once. +void _init() { + if (kIsWeb) { + var platformConfig = VeilidWASMConfig( + logging: VeilidWASMConfigLogging( + performance: VeilidWASMConfigLoggingPerformance( + enabled: true, + level: VeilidConfigLogLevel.debug, + logsInTimings: true, + logsInConsole: false), + api: VeilidWASMConfigLoggingApi( + enabled: true, level: VeilidConfigLogLevel.info))); + Veilid.instance.initializeVeilidCore(platformConfig.json); + } else { + var platformConfig = VeilidFFIConfig( + logging: VeilidFFIConfigLogging( + terminal: VeilidFFIConfigLoggingTerminal( + enabled: false, + level: VeilidConfigLogLevel.debug, + ), + otlp: VeilidFFIConfigLoggingOtlp( + enabled: false, + level: VeilidConfigLogLevel.trace, + grpcEndpoint: "localhost:4317", + serviceName: "VeilidChat"), + api: VeilidFFIConfigLoggingApi( + enabled: true, level: VeilidConfigLogLevel.info))); + Veilid.instance.initializeVeilidCore(platformConfig.json); + } +} + +// Called from FlutterFlow stub initialize() function upon Main page load +bool initialized = false; +Processor processor = Processor(); + +Future initializeVeilid() async { + if (initialized) { + return; + } + + // Init Veilid + _init(); + + // Startup Veilid + await processor.startup(); + + initialized = true; +} diff --git a/lib/veilid_support/processor.dart b/lib/veilid_support/processor.dart new file mode 100644 index 0000000..b618da7 --- /dev/null +++ b/lib/veilid_support/processor.dart @@ -0,0 +1,136 @@ +import 'dart:async'; +import 'package:veilid/veilid.dart'; +import 'config.dart'; +import 'veilid_log.dart'; +import '../log/loggy.dart'; + +class Processor { + String _veilidVersion = ""; + bool _startedUp = false; + Stream? _updateStream; + Future? _updateProcessor; + + Processor(); + + Future startup() async { + if (_startedUp) { + return; + } + + try { + _veilidVersion = Veilid.instance.veilidVersionString(); + } on Exception { + _veilidVersion = 'Failed to get veilid version.'; + } + + // In case of hot restart shut down first + try { + await Veilid.instance.shutdownVeilidCore(); + } on Exception { + // + } + + var updateStream = + await Veilid.instance.startupVeilidCore(await getVeilidChatConfig()); + _updateStream = updateStream; + _updateProcessor = processUpdates(); + _startedUp = true; + + await Veilid.instance.attach(); + } + + Future shutdown() async { + if (!_startedUp) { + return; + } + await Veilid.instance.shutdownVeilidCore(); + if (_updateProcessor != null) { + await _updateProcessor; + } + _updateProcessor = null; + _updateStream = null; + _startedUp = false; + } + + Future processUpdateAttachment( + VeilidUpdateAttachment updateAttachment) async { + //loggy.info("Attachment: ${updateAttachment.json}"); + + // Set connection meter and ui state for connection state + var connectionState = ""; + var checkPublicInternet = false; + switch (updateAttachment.state.state) { + case AttachmentState.detached: + connectionState = "detached"; + break; + case AttachmentState.detaching: + connectionState = "detaching"; + break; + case AttachmentState.attaching: + connectionState = "attaching"; + break; + case AttachmentState.attachedWeak: + checkPublicInternet = true; + connectionState = "weak"; + break; + case AttachmentState.attachedGood: + checkPublicInternet = true; + connectionState = "good"; + break; + case AttachmentState.attachedStrong: + checkPublicInternet = true; + connectionState = "strong"; + break; + case AttachmentState.fullyAttached: + checkPublicInternet = true; + connectionState = "full"; + break; + case AttachmentState.overAttached: + checkPublicInternet = true; + connectionState = "over"; + break; + } + if (checkPublicInternet) { + if (!updateAttachment.state.publicInternetReady) { + connectionState = "attaching"; + } + } + + FFAppState().update(() { + FFAppState().ConnectionState = connectionState; + }); + } + + Future processUpdateConfig(VeilidUpdateConfig updateConfig) async { + //loggy.info("Config: ${updateConfig.json}"); + // xxx: store in flutterflow local state? do we need this for anything? + } + + Future processUpdateNetwork(VeilidUpdateNetwork updateNetwork) async { + //loggy.info("Network: ${updateNetwork.json}"); + // xxx: store in flutterflow local state? do we need this for anything? + } + + Future processUpdates() async { + var stream = _updateStream; + if (stream != null) { + await for (final update in stream) { + if (update is VeilidLog) { + await processLog(update); + } else if (update is VeilidUpdateAttachment) { + await processUpdateAttachment(update); + } else if (update is VeilidUpdateConfig) { + await processUpdateConfig(update); + } else if (update is VeilidUpdateNetwork) { + await processUpdateNetwork(update); + } else if (update is VeilidAppMessage) { + log.info("AppMessage: ${update.json}"); + } else if (update is VeilidAppCall) { + log.info("AppCall: ${update.json}"); + } else { + log.trace("Update: ${update.json}"); + } + } + } + } +} diff --git a/lib/veilid_support/tools.dart b/lib/veilid_support/tools.dart new file mode 100644 index 0000000..ff898ab --- /dev/null +++ b/lib/veilid_support/tools.dart @@ -0,0 +1,16 @@ +import 'package:veilid/base64url_no_pad.dart'; + +bool isValidDHTKey(String key) { + if (key.length != 43) { + return false; + } + try { + var dec = base64UrlNoPadDecode(key); + if (dec.length != 32) { + return false; + } + } catch (e) { + return false; + } + return true; +} diff --git a/lib/veilid_support/veilid_support.dart b/lib/veilid_support/veilid_support.dart new file mode 100644 index 0000000..e69de29 diff --git a/pubspec.lock b/pubspec.lock index 271555a..91c6ae9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -254,6 +254,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.1" + go_router: + dependency: "direct main" + description: + name: go_router + url: "https://pub.dartlang.org" + source: hosted + version: "6.0.1" graphs: dependency: transitive description: @@ -688,4 +695,4 @@ packages: version: "3.1.1" sdks: dart: ">=2.18.6 <3.0.0" - flutter: ">=3.0.0" + flutter: ">=3.3.0" diff --git a/pubspec.yaml b/pubspec.yaml index 13639a6..b0f4e6e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -47,6 +47,7 @@ dependencies: path: ../veilid/veilid-flutter animated_theme_switcher: ^2.0.7 shared_preferences: ^2.0.15 + go_router: ^6.0.1 dev_dependencies: flutter_test: diff --git a/test/widget_test.dart b/test/widget_test.dart index c447c69..3eb36f8 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -5,26 +5,26 @@ // gestures. You can also use WidgetTester to find child widgets in the widget // tree, read text, and verify that the values of widget properties are correct. -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; +// import 'package:flutter/material.dart'; +// import 'package:flutter_test/flutter_test.dart'; -import 'package:veilidchat/main.dart'; +// import 'package:veilidchat/main.dart'; void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); + // testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // // Build our app and trigger a frame. + // await tester.pumpWidget(const MyApp()); - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); + // // Verify that our counter starts at 0. + // expect(find.text('0'), findsOneWidget); + // expect(find.text('1'), findsNothing); - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); + // // Tap the '+' icon and trigger a frame. + // await tester.tap(find.byIcon(Icons.add)); + // await tester.pump(); - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); + // // Verify that our counter has incremented. + // expect(find.text('0'), findsNothing); + // expect(find.text('1'), findsOneWidget); + // }); }