Flutter - How to test page navigation when using a viewmodel with Provider - flutter

I am trying to write widget tests for my Flutter application.
I have a page (MainMenuScreen) as follows (I've left out the imports):
class MainMenuScreen extends StatefulWidget {
#override
_MainMenuScreenState createState() => _MainMenuScreenState();
}
class _MainMenuScreenState extends State<MainMenuScreen> {
#override
void initState() {
super.initState();
}
late MainMenuViewModel vm;
#override
Widget build(BuildContext context) {
vm = Provider.of<MainMenuViewModel>(context);
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: Center(
child: Column(
children: <Widget>[
generateHeightSpacer(Dimensions().heightSpace),
generateLogo(Dimensions().fractionalWidth),
generateHeightSpacer(Dimensions().fractionalWidth),
generateMenuButton(
context,
Dimensions().fractionalWidth,
Dimensions().fractionalHeight,
AppLocalizations.of(context).menuPlay,
LevelSelectionScreen()),
generateHeightSpacer(Dimensions().heightSpace),
generateMenuButton(
context,
Dimensions().fractionalWidth,
Dimensions().fractionalHeight,
AppLocalizations.of(context).menuAbout,
AboutScreen()),
],
),
)),
);
}
}
My main.dart file looks like this:
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
// This widget is the root of your application.
#override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (context) => MainMenuViewModel()),
ChangeNotifierProvider(create: (context) => LevelSelectionViewModel()),
ChangeNotifierProvider(create: (context) => LevelViewModel()),
],
child: MaterialApp(
onGenerateTitle: (context) {
return AppLocalizations.of(context).appTitle;
},
localizationsDelegates: [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: [
Locale('en', ''), // English, no country code
Locale('es', ''), // Spanish, no country code
],
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: ChangeNotifierProvider(
create: (context) => MainMenuViewModel(),
child: MainMenuScreen(),
// ),
),
),
);
}
}
I have looked into how to write a test that can identify when the About screen is displayed, i.e. after pressing the last Menu Button.
So, for now I have something as follows:
testWidgets("Flutter Main Menu Test Menu Button About",
(WidgetTester tester) async {
await tester.pumpWidget(MyApp());
var button = find.text("About");
expect(button, findsOneWidget);
});
This allows me to identify that the About button is displayed. (Note: I will be changing my code and tests to do this based on a 'key' rather than text.)
From what I've read the way to test is to use Mockito and a NavigationObserver but that seems to require the ability to inject into the screen. I have also been looking at other solutions which seem to suggest wrapping the provider, i.e.:
I've been following this page(https://iiro.dev/writing-widget-tests-for-navigation-events/) to try to test but I can't quite figure out how to pass the viewmodel in.
This is my test at the moment:
class MockMainMenuViewModel extends Mock implements MainMenuViewModel {}
class MockNavigatorObserver extends Mock implements NavigatorObserver {}
void main() {
group('MainMenuScreen navigation tests', () {
late NavigatorObserver mockObserver;
MockMainMenuViewModel? mockViewModel;
setUp(() {
mockObserver = MockNavigatorObserver();
mockViewModel = MockMainMenuViewModel();
});
Future<void> _buildMainPage(WidgetTester tester) {
return tester.pumpWidget(MaterialApp(
home: MainMenuScreen(mockViewModel),
navigatorObservers: <NavigatorObserver>[observer],
));
}
});
}
Any help is appreciated please.

Related

Internationalization with cubit

I'm working on a project and I've been asked to use cubit for internationalization, preferably using the lazy method. For that I have a LocalizationContainer as follows:
class LocalizationContainer extends BlocContainer {
final Widget child;
LocalizationContainer({required this.child});
#override
Widget build(BuildContext context) {
return BlocProvider<CurrentLocaleCubit>(
create: (context) => CurrentLocaleCubit(),
child: child,
);
}
}
class CurrentLocaleCubit extends Cubit<String> {
CurrentLocaleCubit() : super("pt-br");
CurrentLocaleCubit() : super("en-us");
}
In my main file I have the following:
MaterialApp(
title: 'Example',
theme: exampleTheme(context),
debugShowCheckedModeBanner: false,
home: LocalizationContainer(
child: InitialScreenContainer(),
),
);
In this example the child of LocalizationContainer is another container representing the screen. Each screen is structured into container, cubit and view:
The container for screen have the following structure:
class ExampleScreenContainer extends BlocContainer {
#override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => ExampleScreenCubit(),
child: I18NLoadingContainer(
language: BlocProvider.of<CurrentLocaleCubit>(context).state,
viewKey : "Example",
creator: (messages) => ExampleScreenView(ExampleScreenViewLazyI18N(messages)),
),
);
}
}
Everytime a new page needs to be opened, I do the following:
Navigator.of(blocContext).push(
MaterialPageRoute(
builder: (context) => BlocProvider.value(
value: BlocProvider.of<CurrentLocaleCubit>(blocContext),
child: NewScreenContainer(),
),
),
);
But whenever I try to hot reload a error pops up. It only works if I do the hot restart. Does somebody know how to solve this problem, or this internationalization method is wrong?
I did not really get the problem (I think if you put the error that's pop up I can help you more), but this way I do localizations (I use bloc).
first off all you need to add BlocProvider above MaterialApp so he become ancestor to every widget in context tree, so when ever you called BlocProvider.of(context)
you can get the instance of this bloc where ever you are in the tree (no need to do BlocProvider above every screen you are pushing).
now when ever you change language of your app and yield the new state the BlocBuilder will rebuild the whole app with the new language.
class AppProvider extends StatelessWidget {
#override
Widget build(BuildContext context) {
return MultiBlocProvider(providers: [
BlocProvider<AppBloc>(
create: (_) => sl<AppBloc>()
//get app default language
..add(const AppEvent.initialEvent()),
),
], child: App());
}
}
class App extends StatelessWidget {
#override
Widget build(BuildContext context) {
return BlocBuilder<AppBloc, AppState>(
builder: (context, state) => MaterialApp(
debugShowCheckedModeBanner: false,
home: SplashScreen()),
locale: state.language == AppLanguageKeys.AR
? const Locale('ar', '')
: const Locale('en', ''),
localizationsDelegates: [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
supportedLocales: [
const Locale('en', ''), // English
const Locale('ar', ''), // Arabic
],
),
);
}
}

Flutter main.dart initialRout is not Working

I'm New in this FrameWork and here initial Rout is not Accepting the Loggin Session value Please help me with this. I tried to add Home with the splash screen but that also not working I'm not getting What's wrong in this.
This is my main Page
Future main() async {
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
));
runApp(Phoenix(child: AmericanCuisine()));
}
class AmericanCuisine extends StatefulWidget {
#override
_AmericanCuisineState createState() => _AmericanCuisineState();
}
class _AmericanCuisineState extends State<AmericanCuisine> {
bool isLoggedIn;
#override
void initState() {
super.initState();
getData();
}
getData() async {
WidgetsFlutterBinding.ensureInitialized();
SharedPreferences storage = await SharedPreferences.getInstance();
setState(() {
isLoggedIn = storage.getBool("loggedIn");
});
}
#override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider<LanguageCubit>(
create: (context) => LanguageCubit(),
),
BlocProvider<ThemeCubit>(
create: (context) => ThemeCubit(),
),
],
in this page after using BlockBuilder how i To give the Initial Route
child: BlocBuilder<ThemeCubit, ThemeData>(
builder: (_, theme) {
return BlocBuilder<LanguageCubit, Locale>(
builder: (_, locale) {
return MaterialApp(
localizationsDelegates: [
const AppLocalizationsDelegate(),
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
supportedLocales: [
const Locale('en'),
],
locale: locale,
theme: theme,
//This initial rout is not working.
initialRoute: isLoggedIn == false ?'/': '/homeOrderAccount',
routes: {
// When navigating to the "/" route, build the FirstScreen widget.
'/': (context) => OpeningScreen(),
'/homeOrderAccount': (context) => HomeOrderAccount(),
},
);
},
);
},
),
);
}
}
You can't use initialRoute with routes map either delete '/' from the routes map or delete the initialRoute:

TextFormField does not reflect initialValue from a ChangeNotifierProvider

I am trying to set the initialValue of my TextFormField from my provider. This works but not on inital load of my screen. For this to work, I need to go to previous screen, then come back. Here are the relevant source codes.
User class:
class User with ChangeNotifier {
String name;
String email;
User() {
_deserialiseUser();
}
}
void _deserialiseUser() async {
final prefs = await SharedPreferences.getInstance();
final String jsonUser = prefs.getString('user');
if (jsonUser != null) {
var ju = jsonDecode(jsonUser);
name = ju['name'];
email = ju['email'];
notifyListeners();
}
}
Main class:
void main() => runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (context) => User()),
...
],
child: MyApp(),
),
);
The affected screen:
class AccountScreen extends StatefulWidget {
#override
_AccountScreenState createState() => _AccountScreenState();
}
class _AccountScreenState extends State<AccountScreen> {
User user;
#override
Widget build(BuildContext context) {
user = context.watch<User>();
return Scaffold(
appBar: AppBar(
title: Text('Account'),
),
body: TextFormField(
initialValue: user.name,
),
);
}
}
If I replace my TextFormField with Text, this works fine. What am I missing?
EDIT: Although I already have the answer, I will still accept answers that can explain the root cause of the issue.
I solved the issue by not lazily loading my User provider like so:
void main() => runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(
create: (context) => User(),
lazy: false,
),
],
child: MyApp(),
),
);

Flutter Routes Emulators keeps rendering '/' instead of InitialRoute

New to Flutter.
Here is the content of my main page:
void main() => runApp(MaterialApp(
initialRoute: '/location',
routes: {
'/': (context)=> Loading(),
'/location': (context)=> Location(),
'/home': (context)=> Home(),
},
));
Consider that in my '/': (context)=> Loading(), there is a function that is executed in initState at the end of which it redirects to '/home', but as you can see in my main.dart I set the initial route to '/location' so the emulators is not supposed to open the loading file in order to be redirected to the home file, however it keeps happening.
When I clean & run my emulator, it opens on the location file and then somehow the function in the loading file gets executed which takes me back to the home file.
I do not know if I am making sense, please let me know if you have a hard time understanding.
Optional, here is my loading.dart function:
class _LoadingState extends State<Loading> {
String time;
void fetchIt() async {
final WorldTimeClass defaultinstance = WorldTimeClass(flag: 'Algiers', url: 'Africa/Algiers', location: 'Algiers');
DateTime getTimeFormat = await defaultinstance.getTimeFormat();
await defaultinstance.getTimeHumanFormat(getTimeFormat);
// here the navigator to home.dart
await Navigator.pushReplacementNamed(context, '/home', arguments: {
'location': defaultinstance.location,
'time': defaultinstance.time,
'isDayTime': defaultinstance.isDayTime
});
}
#override
void initState() {
time = 'loading...';
super.initState();
fetchIt(); // here the function executed
}
...
Kindly, explain to me why is this happening?
I solved this by changing -in the routes: argument- :
'/home' to 'home',
'/location' to 'location', and
kept '/' as is.
I do not know why this solved the problem but, it works now.
the route represented by '/' must be your initial route that is the screen that you want to show when your app is started . Otherwise if you use '/' for any other screen , that screen will run behind the scenes . Use the code below as an example . I want to show Page 2 as the home screen that is why I have set initial route to '/' and
'/':(BuildContext ctx)=>Page2()
import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
// import 'package:path_provider/path_provider.dart';
void main() => runApp(Example());
class Example extends StatelessWidget {
Widget build(BuildContext context) {
return MaterialApp(
title: "Example",
initialRoute: '/',
routes: {
'/':(BuildContext ctx)=>Page2(),
'/page1':(BuildContext ctx)=>Page1(),
'/page3':(BuildContext ctx)=>Page3()
},
);
}
}
class Page1 extends StatefulWidget{
Page1State createState()=> Page1State();
}
class Page1State extends State<Page1> {
#override
void initState() {
super.initState();
timerFunction();
}
Timer timerFunction(){
return new Timer(Duration(seconds: 3),handleTimeout);
}
handleTimeout(){
Navigator.pushNamed(context, '/page3');
}
#override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Text("Page1"),
),
);
}
}
class Page2 extends StatelessWidget{
#override
Widget build(BuildContext context) {
return Scaffold(
body:Container(
alignment: Alignment.center,
child :Column(
children:<Widget>[
Padding(padding: EdgeInsets.only(top: 100)),
Text("Page2"),
RaisedButton(
child: Text("go to page1"),
onPressed: ()=>Navigator.pushNamed(context, '/page1'),
)
]
),
),
);
}
}
class Page3 extends StatelessWidget{
#override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Text("Page3"),
),
);
}
}
First, try to restart the app by re-running flutter run

Flutter provider state management, logout concept

I am trying to implement custom logout solution for my application, where no matter where user currently is, once the Logout button is clicked, app will navigate back to Login page.
My idea was, that instead of listening on every component for state changes, I would have one single listener on a master component -> MyApp.
For the sake of simplicity, I have stripped down items to bare minimum. Here is how my Profile class could look like:
class Profile with ChangeNotifier {
bool _isAuthentificated = false;
bool get isAuthentificated => _isAuthentificated;
set isAuthentificated(bool newVal) {
_isAuthentificated = newVal;
notifyListeners();
}
}
Now, under Main, I have registered this provider as following:
void main() => runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(
create: (_) => Profile(),
)
],
child: MyApp(),
),
);
And finally MyApp component:
class MyApp extends StatelessWidget {
// This widget is the root of your application.
#override
Widget build(BuildContext context) {
return Consumer<Profile>(
builder: (context, profile, _) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
brightness: Brightness.light,
primaryColor: Color.fromARGB(255, 0, 121, 107),
accentColor: Color.fromARGB(255, 255, 87, 34),
),
home: buildBasePage(context, profile),
);
},
);
}
Widget buildBasePage(BuildContext context, Profile currentProfile) {
return !currentProfile.isAuthentificated
? LoginComponent()
: MyHomePage(title: 'Flutter Demo Home Page test');
}
}
My idea was, that as MyApp component is the master, I should be able to create a consumer, which would be notified if current user is authentificated, and would respond accordingly.
What happens is, that when I am in e.g. MyHomePage component and I click Logout() method which looks like following:
void _logout() {
Provider.of<Profile>(context, listen: false).isAuthentificated = false;
}
I would be expecting that upon changing property, the initial MyApp component would react and generate LoginPage; which is not the case. I have tried changing from Consumer to Provider.of<Profile>(context, listen: false) yet with the same result.
What do I need to do in order for this concept to work? Is it even correct to do it this way?
I mean I could surely update my Profile class in a way, that I add the following method:
logout(BuildContext context) {
_isAuthentificated = false;
Navigator.push(
context, MaterialPageRoute(builder: (context) => LoginComponent()));
}
And then simply call Provider.of<Profile>(context, listen: false).logout(), however I thought that Provider package was designed for this...or am I missing something?
Any help in respect to this matter would be more than appreciated.
I don't know why it wasn't working for you. Here is a complete example I built based on your description. It works!
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class Profile with ChangeNotifier {
bool _isAuthentificated = false;
bool get isAuthentificated {
return this._isAuthentificated;
}
set isAuthentificated(bool newVal) {
this._isAuthentificated = newVal;
this.notifyListeners();
}
}
void main() {
return runApp(
MultiProvider(
providers: [
ChangeNotifierProvider<Profile>(
create: (final BuildContext context) {
return Profile();
},
)
],
child: MyApp(),
),
);
}
class MyApp extends StatelessWidget {
#override
Widget build(final BuildContext context) {
return Consumer<Profile>(
builder: (final BuildContext context, final Profile profile, final Widget child) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(primarySwatch: Colors.blue),
home: profile.isAuthentificated ? MyHomePage() : MyLoginPage(),
);
},
);
}
}
class MyHomePage extends StatelessWidget {
#override
Widget build(final BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Home [Auth Protected]")),
body: Center(
child: RaisedButton(
child: const Text("Logout"),
onPressed: () {
final Profile profile = Provider.of<Profile>(context, listen: false);
profile.isAuthentificated = false;
},
),
),
);
}
}
class MyLoginPage extends StatelessWidget {
#override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Login")),
body: Center(
child: RaisedButton(
child: const Text("Login"),
onPressed: () {
final Profile profile = Provider.of<Profile>(context, listen: false);
profile.isAuthentificated = true;
},
),
),
);
}
}
You don't need to pass listen:false, instead simply call
Provider.of<Profile>(context).logout()
So your Profile class would look like
class Profile with ChangeNotifier {
bool isAuthentificated = false;
logout() {
isAuthentificated = false;
notifyListeners();
}
}