I wrote Flutter Google Login with Firebase before but its implementation doesn’t keep the login state. The login state is actually stored in Firebase but it doesn’t use the info. Therefore, a user always has to do the login process. It is not nice for a user.
We try to solve the problem in this article. The sample movie is at the end of this post.
GoogleAuthClient, token and api providers
To use google API, an authorization header needs to be added to the request.
class _GoogleAuthClient extends http.BaseClient {
final Map<String, String> _headers;
final _client = new http.Client();
_GoogleAuthClient(this._headers);
@override
Future<http.StreamedResponse> send(http.BaseRequest request) {
request.headers.addAll(_headers);
return _client.send(request);
}
}
final tokenProvider = StateProvider<String?>((ref) => null);
final apiProvider = StateProvider<DriveApi?>((ref) {
final token = ref.watch(tokenProvider);
if (token == null) {
return null;
}
final headers = {"Authorization": "Bearer $token"};
return DriveApi(_GoogleAuthClient(headers));
});
The token is null until a user logins to Google. Once it gets a token, a new DriveApi instance is provided.
Login page
The code for the Login page is as follows. It’s not necessary to put logic to go to another page because it is handled in SplashScreen class which is shown later.
If you don’t know about scopes
property, the following posts might be helpful.
Flutter Upload data to Google Drive
Flutter Google Login with Firebase
final _google = GoogleSignIn.standard(scopes: [
DriveApi.driveAppdataScope,
DriveApi.driveFileScope,
]);
class _LoginPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
_autoLogin(ref);
return Scaffold(
appBar: AppBar(
title: Text("Login page"),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
ElevatedButton(
onPressed: () async {
await _login(ref);
// not necessary to put nagivator here
},
child: const Text("Login"),
),
],
),
),
);
}
Future<void> _login(WidgetRef ref) async {
final googleUser = await _google.signIn();
if (googleUser == null) {
return;
}
try {
_afterLoginProcess(ref, googleUser);
} catch (e) {
print(e);
}
}
}
Future<void> _afterLoginProcess(
WidgetRef ref,
GoogleSignInAccount googleUser,
) async {
final googleAuth = await googleUser.authentication;
ref.read(tokenProvider.state).state = googleAuth.accessToken;
final credential = GoogleAuthProvider.credential(
accessToken: googleAuth.accessToken,
idToken: googleAuth.idToken,
);
final UserCredential loginUser =
await FirebaseAuth.instance.signInWithCredential(credential);
assert(loginUser.user?.uid == FirebaseAuth.instance.currentUser?.uid);
}
I tried to store the access token into shared preferences but it didn’t work as expected. It worked until the access token was expired. However, I couldn’t handle the case if a user closes the app and opens it again after the access token is expired. If the access token is expired, we need to re-authenticate. It means that we need to call either signIn()
or signInSilently()
method.
Prepare a Splash screen
Go to another page depending on the current state
What we want to achieve is to open
- login page when the user has not login
- home page when the user has already login
To check the current state, the app must fetch the data to Firebase which is done asynchronously. While it’s in progress, our app shows a splash screen. To make it simple, it just shows a text.
class GoogleAutoLogin extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SplashScreen();
}
}
class SplashScreen extends ConsumerWidget {
const SplashScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
// Don't add await keyword
_onAuthStateChange(context, ref);
return const Scaffold(
body: Center(child: Text("your splash screen")),
);
}
Future<void> _onAuthStateChange(BuildContext context, WidgetRef ref) async {
// this delay is used to make the view visible
Future.delayed(Duration(seconds: 1), () {
FirebaseAuth.instance.authStateChanges().listen((user) async {
try {
if (user == null) {
navigatorKey.currentState?.pushReplacement(
MaterialPageRoute(
builder: (context) => _LoginPage(),
),
);
return;
}
navigatorKey.currentState?.pushReplacement(
MaterialPageRoute(
builder: (context) => _Home(),
),
);
} catch (e) {
print(e);
}
});
});
}
}
_onAuthStateChange
returns Future but we don’t add await keyword in the build method because it doesn’t need the result. FirebaseAuth.instance.authStateChanges().listen()
notifies the current user. user is null at first but it is not null if a user has logged in before.
Every time the login state changes, it triggers the listener. It means that page transition is handled here when a user does login/logout. We don’t have to add additional code to the other pages.
You might think why it’s not using Navigator.of(context)
. Once page transition is done here, the context will be no longer valid. However, the listener is triggered if the login state changes. As a result, an error Null check operator used on a null value
occurs here because the context is not valid. We want to do the page transition without using context. For that reason, the global navigator is used here.
The navigator key must be specified in MaterialApp
.
final navigatorKey = new GlobalKey<NavigatorState>();
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
runApp(
ProviderScope(
child: MaterialApp(
... // something else
navigatorKey: navigatorKey,
),
),
);
}
Handling auto login after re-opening the app
When a user is not logged in, the login page is shown and a user does log in. The app can get an access token for Google API and tokenProvider is updated. Then, DriveApi instance is provided with the right token. However, the state is initialized when a user closes the app. If the user doesn’t log out, the login page is not shown because FirebaseAuth.instance.authStateChanges().listen() gives us a login user. The app shows the Home page where Google API uses without an access token. Of course, the app doesn’t read anything without an access token. We somehow need to handle this case.
We don’t want to have a user do the login process again, therefore the app should try login silently. I thought this could be used only if the app had called signIn()
method before in the same session. In other words, I thought it could be used only when _google.currentUser
is not null but it seems not to be the case.
class SplashScreen extends ConsumerWidget {
Future<void> _onAuthStateChange(BuildContext context, WidgetRef ref) async {
// Added
_autoLogin(ref);
Future.delayed(Duration(seconds: 1), () {
...
});
}
}
Future<void> _autoLogin(WidgetRef ref) async {
final googleUser = await _google.signInSilently(reAuthenticate: true);
if (googleUser == null) {
return;
}
_afterLoginProcess(ref, googleUser);
}
Future<void> _afterLoginProcess(
WidgetRef ref,
GoogleSignInAccount googleUser,
) async {
final googleAuth = await googleUser.authentication;
ref.read(tokenProvider.state).state = googleAuth.accessToken;
final credential = GoogleAuthProvider.credential(
accessToken: googleAuth.accessToken,
idToken: googleAuth.idToken,
);
final UserCredential loginUser =
await FirebaseAuth.instance.signInWithCredential(credential);
assert(loginUser.user?.uid == FirebaseAuth.instance.currentUser?.uid);
}
After this silent login, the listener registered in FirebaseAuth.instance.authStateChanges().listen()
is triggered with non-null user, and thus Home page is shown.
By the way, if a new token is required every time the app tries to call Google API, we should pass a property like this.
final googleUser = await _google.signInSilently(reAuthenticate: true);
Get a new access token when it is expired
The access token has an expiration time. The expiration time seems to be 30 minutes. According to the official page, It seems to be possible to change the expiration time but I haven’t found the way in Flutter.
This Home page shows a file list of Google Drive. It looks simple page.
class _Home extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final appBar = AppBar(
title: Text("Home"),
actions: [
TextButton(
onPressed: () async {
await _google.signOut();
await FirebaseAuth.instance.signOut();
},
child: Text(
"Logout",
style: TextStyle(color: Colors.white),
),
)
],
);
return Scaffold(
appBar: appBar,
body: FutureBuilder<List<File>?>(
future: _fetchList(ref),
builder: (context, snapshot) {
final data = snapshot.data;
if (data == null) {
return Center(child: CircularProgressIndicator());
}
if (data.isEmpty) {
return Center(child: Text("No data found"));
}
return ListView.builder(
itemCount: data.length,
itemBuilder: (context, index) {
return ListTile(
title: Text("${data[index].name}"),
);
},
);
},
),
);
}
...
}
If you don’t know how to use FutureBuilder
widget, Flutter Add TextField widget dynamically might be helpful.
I haven’t found a method that checks whether the access token is expired or not. It might not exist. The way I could do is to catch the Invalid Credentials error. If DetailedApiRequestError
is thrown, the status is 401, and the message is Invalid Credentials
, signInSilently()
is called to get a new access token.
class _Home extends ConsumerWidget {
...
Future<List<File>> _fetchList(WidgetRef ref) async {
final api = ref.watch(apiProvider.state).state;
if (api == null) {
return [];
}
try {
final fileList = await api.files.list(
spaces: 'appDataFolder',
$fields: 'files(name)',
);
return fileList.files ?? [];
} on DetailedApiRequestError catch (e) {
print(e);
if (e.status == 401 && e.message == "Invalid Credentials") {
final googleUser = await _google.signInSilently();
if (googleUser == null) {
return [];
}
_afterLoginProcess(ref, googleUser);
}
} catch (e) {
print(e);
}
return [];
}
}
In _afterLoginProcess
method, tokenProvider
is updated and thus apiProvider
is also updated. Since tokenProvider
is watched in the _fetchList
method, the whole process is repeated because the widget tree is rebuilt. Therefore, it can fetch the file list correctly for the second try.
It is better to put the re-authentication logic in _GoogleAuthClient
class because all messages are sent from here. I tried to do that but it was not possible because it is not a widget, and thus it doesn’t have WidgetRef
which is required by Riverpod provider.
Comments