- Set up Flutter and the Amplify CLI (follow the Project setup guide)
- Create a new Flutter app with
flutter create flutter_amplify_datastore
- Open the project in VS Code
- Delete the folders for
web
,macos
,linux
andwindows
- Uncomment the following line in
ios/Podfile
and set the value to13
:
platform :ios, '13.0'
- Set the
minSdkVersion
inandroid/app/build.gradle
to21
:
defaultConfig {
...
minSdkVersion 24
...
}
- Create a new folder
screens
in thelib
folder - Add a new file
home_screen.dart
in thescreens
folder - Move the
MyHomePage
class frommain.dart
tohome_screen.dart
and rename it toHomeScreen
- Run
flutter pub add go_router
to add GoRouter to your project - In
main.dart
, add the following GoRouter config:
// GoRouter configuration
static final _router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) =>
const HomeScreen(title: 'Flutter Demo Home Page'),
),
],
);
- Replace
MaterialApp(...)
withMaterialApp.router()
:
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
routerConfig: _router,
);
}
- Run
flutter run
to see if everything works as expected
[Image: 01_counter.gif]
flutter pub add amplify_flutter
amplify init
- Confirm the default values for all questions
- Add a new file
amplify.dart
in thelib
folder with the following content:
import 'package:amplify_flutter/amplify_flutter.dart';
import './amplifyconfiguration.dart';
Future<void> configureAmplify() async {
try {
// Plugins will be added here
// call Amplify.configure to use the initialized categories in your app
await Amplify.configure(amplifyconfig);
} on AmplifyAlreadyConfiguredException {
safePrint(
'Tried to reconfigure Amplify; this can occur when your app restarts on Android.');
} catch (e) {
safePrint('Error during Amplify configuration:\n${e.toString()}');
}
}
- In
main.dart
, updated themain()
method to the following:
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await configureAmplify();
runApp(const MyApp());
}
- Install the DataStore package:
flutter pub add amplify_datastore
- Run
amplify add api
- Select
GraphQL
as the API type - Select
Amazon Cognito User Pool
as the authentication type - Enable
conflict detection
and selectAuto Merge
as the resolution strategy - Select
Blank schema
as the schema template - Replace the contents of
amplify/backend/api/flutter_amplify_datastore/schema.graphql
with the following schema:
type Workout @model @auth(rules: [{ allow: owner }]) {
id: ID!
owner: String @auth(rules: [{ allow: owner, operations: [read, delete] }])
createdAt: AWSDateTime!
startedAt: AWSDateTime
finishedAt: AWSDateTime
}
amplify codegen models
We can now use the Workout
model in our Flutter app.
- In
amplify.dart
, add the following:
import 'package:amplify_datastore/amplify_datastore.dart';
import './models/ModelProvider.dart';
...
// Plugins will be added here
final datastorePlugin =
AmplifyDataStore(modelProvider: ModelProvider.instance);
await Amplify.addPlugin(datastorePlugin);
...
- Replace
_HomeScreenState
inscreens/home_screen.dart
with the following:
class _HomeScreenState extends State<HomeScreen> {
// We will use this subscription to update the list of workouts when a new one is created
// The first snapshot will include all workouts, subsequent snapshots will only include new workouts
StreamSubscription<QuerySnapshot<Workout>>? _workoutSubscription;
List<Workout> _workouts = [];
void _observeWorkouts() {
_workoutSubscription = Amplify.DataStore.observeQuery(Workout.classType)
.listen((QuerySnapshot<Workout> snapshot) {
setState(() {
_workouts = snapshot.items;
});
});
}
void _initialize() {
_observeWorkouts();
}
@override
void initState() {
super.initState();
_initialize();
}
@override
void dispose() {
_workoutSubscription?.cancel();
super.dispose();
}
Future<void> _createWorkout() async {
final workout = Workout(
createdAt: TemporalDateTime.now(),
);
try {
await Amplify.DataStore.save(workout);
_queryWorkouts();
} on DataStoreException catch (e) {
safePrint('Could not create workout: ${e.message}');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: _workouts.isEmpty
? const Center(
child: Text(
'No workouts yet. Press the + button to create one.',
textAlign: TextAlign.center,
),
)
: RefreshIndicator(
onRefresh: _queryWorkouts,
child: ListView.separated(
itemCount: _workouts.length,
itemBuilder: (context, index) => ListTile(
title: Text('Workout ${index + 1}'),
subtitle: Text(_workouts[index].createdAt.toString()),
),
separatorBuilder: (context, index) => const Divider(
height: 0,
),
),
),
floatingActionButton: FloatingActionButton(
onPressed: _createWorkout,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
Read through the code and make sure you understand what's going on.
- Run
flutter run
to see if everything works as expected - When you click the
+
button, you should see a new item in the list - The item is stored locally on the device
[Image: 02_list.gif]
- Create a new file
details_screen.dart
in thescreens
folder with the following content:
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:amplify_flutter/amplify_flutter.dart';
import '../models/Workout.dart';
class DetailsScreen extends StatefulWidget {
const DetailsScreen({
super.key,
required this.workout,
required this.workoutNumber,
});
final Workout workout;
final int workoutNumber;
@override
State<DetailsScreen> createState() => _DetailsScreenState();
}
class _DetailsScreenState extends State<DetailsScreen> {
Future<void> _handleDelete() async {
try {
await Amplify.DataStore.delete(widget.workout);
if (mounted) {
context.pop();
}
} on DataStoreException catch (e) {
safePrint('Could not delete workout: ${e.message}');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Workout Details'),
),
body: SizedBox(
width: double.infinity,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Workout ${widget.workoutNumber}'),
Text('Created at: ${widget.workout.createdAt}'),
TextButton(
onPressed: _handleDelete,
child: const Text(
'Delete',
style: TextStyle(color: Colors.red),
),
)
],
),
),
);
}
}
- In
main.dart
, add the following route to the router config:
GoRoute(
path: '/workout/:id',
builder: (context, state) {
// final id = state.pathParameters['id'];
final extra = state.extra as Map;
final workout = extra['workout'] as Workout;
final workoutNumber = extra['workoutNumber'] as int;
return DetailsScreen(
workout: workout,
workoutNumber: workoutNumber,
);
},
),
- Add the following method to
_HomeScreenState
(Don't forget to import GoRouter):
void _handleWorkoutTap(Workout workout) {
context.push('/workout/${workout.id}', extra: {
'workout': workout,
'workoutNumber': _workouts.indexOf(workout) + 1,
});
}
- Set the onTap handler of the
ListTile
in_HomeScreenState
to_handleWorkoutTap
:
onTap: () => _handleWorkoutTap(_workouts[index]),
Now when you tap on a workout in the list, you should see the details screen. When you tap the delete button, the workout should be deleted from the list. You will need to refresh the list by pulling down to see the changes.
[Image: 03_details.gif]
It is recommended to develop without cloud synchronization enabled initially so you can change the schema as your application takes shape without the impact of having to update the provisioned backend. Once you are satisfied with the stability of your data schema, setup cloud synchronization as described below and the data saved locally will be synchronized to the cloud automatically.
-
Run
flutter pub add amplify_auth_cognito amplify_api
-
Run
amplify add auth
and selectEmail
as the sign-in method -
Run
amplify push
to push your changes to the cloud -
In
amplify.dart
, add the following:
import 'package:amplify_auth_cognito/amplify_auth_cognito.dart';
import 'package:amplify_api/amplify_api.dart';
...
// Plugins will be added here
final authPlugin = AmplifyAuthCognito();
await Amplify.addPlugin(authPlugin);
final api = AmplifyAPI();
await Amplify.addPlugin(api);
...
- Add the Amplify Authenticator package:
flutter pub add amplify_authenticator
- Create a new file
login_screen.dart
in thescreens
folder with the following content:
import 'package:flutter/material.dart';
import 'package:amplify_flutter/amplify_flutter.dart';
import 'package:amplify_authenticator/amplify_authenticator.dart';
import 'package:go_router/go_router.dart';
class LoginScreen extends StatelessWidget {
const LoginScreen({super.key});
void _handleLogoutTap(BuildContext context) async {
await Amplify.Auth.signOut();
if (context.mounted) {
context.pop();
}
}
@override
Widget build(BuildContext context) {
return Authenticator(
child: MaterialApp(
builder: Authenticator.builder(),
home: Scaffold(
body: Center(
child: Column(
children: [
const Text('You are logged in!'),
TextButton(
onPressed: () => _handleLogoutTap(context),
child: const Text('Log out'),
),
],
),
),
),
),
);
}
}
- In
main.dart
, add the following route to the router config:
GoRoute(
path: '/login',
builder: (context, state) => const LoginScreen(),
),
- In
_HomeScreenState
, add the following to theAppBar
:
actions: [
IconButton(
onPressed: () => context.push('/login'),
icon: const Icon(Icons.person),
),
],
- Restart your app
Now you should see a login screen when you tap the person icon in the app bar. You can create a new account or sign in with an existing one.
[Image: 04_login.gif]
When the user signs in or out, we want to clear the list of workouts.
- In
home_screen.dart
, add the following to_HomeScreenState
:
StreamSubscription<AuthHubEvent>? _authSubscription;
void _observeAuthEvents() {
_authSubscription =
Amplify.Hub.listen(HubChannel.Auth, (AuthHubEvent event) {
switch (event.type) {
case AuthHubEventType.signedIn:
_onLogin();
break;
case AuthHubEventType.signedOut:
_onLogout();
break;
default:
break;
}
});
}
void _onLogin() async {
_workoutSubscription?.cancel();
await Amplify.DataStore.clear();
_observeWorkouts();
}
void _onLogout() async {
_workoutSubscription?.cancel();
await Amplify.DataStore.clear();
_observeWorkouts();
}
...
void _handleAuthEvent(AuthHubEvent event) {
switch (event.type) {
case AuthHubEventType.signedOut:
showDialog(
context: context,
builder: (context) => const AlertDialog(
title: Text('Signed out'),
content: Text('You have been signed out.'),
),
);
break;
case AuthHubEventType.signedIn:
showDialog(
context: context,
builder: (context) => const AlertDialog(
title: Text('Signed in'),
content: Text('You have been signed in.'),
),
);
break;
default:
break;
}
}
@override
void initState() {
super.initState();
_queryWorkouts();
_authSubscription = Amplify.Hub.listen(HubChannel.Auth, _handleAuthEvent);
}
@override
void dispose() {
_authSubscription.cancel();
super.dispose();
}
Restart your app. As you log in and out, you will see that the list of workouts is cleared.
Since we are creating an offline-first app, we want to sync the workouts that were created before the user logged in (or possible even before the user created an account).
To accomplish this, we have to copy the existing workouts and save them as the logged in user after the user logs in.
We have to make sure that the sync is completed before we reset the workout list. Otherwise, the workouts would be deleted before they are synced.
Amplify doesn't allow us to copy workouts without the id, so we have to create a new workout with the same id and copy the other properties.
The code for this is not straightforward, so make sure you understand what's going on.
- In
home_screen.dart
, replace_onLogin()
with the following:
void _onLogin() async {
_workoutSubscription?.cancel();
if (_workouts.isEmpty) {
Amplify.DataStore.clear();
_observeWorkouts();
}
if (_workouts.isNotEmpty) {
// Add local workouts to account
// We need to recreate the workout objects without the ID
final localWorkouts = await Amplify.DataStore.query(Workout.classType);
final localWorkoutsWithoutId = localWorkouts.map((workout) => Workout(
createdAt: workout.createdAt,
startedAt: workout.startedAt,
finishedAt: workout.finishedAt,
));
// Keep track of which workouts have yet to be synced
// We use the createdAt timestamp as a (sufficiently) unique identifier
List<String> pendingWorkoutTimestamps = localWorkoutsWithoutId
.map((workout) => workout.createdAt.toString())
.toList();
// Subscription to wait for syncing to finish before clearing
late final StreamSubscription<DataStoreHubEvent> stream;
stream = Amplify.Hub.listen(HubChannel.DataStore, (event) async {
if (event.type == DataStoreHubEventType.outboxMutationProcessed) {
final processedEvent = event.payload as OutboxMutationEvent;
if (processedEvent.modelName == Workout.classType.modelName()) {
final workout = processedEvent.element.model as Workout;
pendingWorkoutTimestamps.remove(workout.createdAt.toString());
if (pendingWorkoutTimestamps.isEmpty) {
safePrint('All workouts synced');
stream.cancel();
Amplify.DataStore.clear();
_observeWorkouts();
}
}
}
});
// Save local workouts to account
try {
for (final workout in localWorkoutsWithoutId) {
await Amplify.DataStore.save(workout);
}
} on DataStoreException catch (e) {
safePrint('Could not save workouts: ${e.message}');
}
}
}
And that's it! Now you have a fully functional offline-first app with cloud synchronization.