Flutter: Simple CRUD with Firebase & Cubit
In this article, we will create a simple CRUD using Firebase and Cubit that will allow us to create, update and delete user information.
The final result will look like this:
Final result. Login, home, and edit screens
The authentication flow will be handled by FlutterFire UI, so we will not have to build it from scratch.
Some of the packages that we will use are:
- State management: Cubit
- Dependency Injection: GetIt
- Authentication UI: FlutterFire UI
- Database: Cloud Firestore
- File Storage: Firebase Storage
Source code
The source code can be downloaded from GitHub:
Workflow
The workflow or user flow can be seen in the next image:
- Splash screen: When the user opens the app, we will show a splash screen while we check if the user is signed-in. If the user is signed-in, then we show the Home screen otherwise, we show the Intro screen.
- Intro screen: In this screen, we will have a
PageView
to show the introduction of the app and the login section. - Home screen: On the home screen, we will have a
ListView
with the list of users that we created. Pressing one of the users will open the Edit screen. There is also a+
button that will open the Create screen - Edit or Create screen: This is the screen where we can edit or create new users. We can upload a new image and set the name, last name, and age.
Architecture
We are going to separate our code into three main layers:
- Presentation: The presentation layer's responsibility is to figure out how to render itself based on the current state. In addition, it should handle user input and application lifecycle events.
- Business Logic: The business logic layer's responsibility is to respond to input from the presentation layer with new states. This layer can depend on one or more repositories to retrieve data needed to build up the application state.
- Data Layer: The data layer's responsibility is to retrieve/manipulate data from one or more sources. It can be split into parts:
- Repository: The repository layer is a wrapper around one or more data providers with which our business layer communicates
- Data Source: The data provider's responsibility is to provide raw data. The data provider should be generic and versatile.
The business logic layer will contain the cubits that communicate with the presentation layer through events. This layer is a bridge between the UI and the data layer.
Setting up Firebase
In this article, we will not cover how to set up Firebase.
The step-by-step instructions are available in the official documentation Firebase documentation
Model: MyUser
The MyUser
class will define the properties of the user we want to store in Firebase. For example, the name
, the lastname
, etc.
// Extending Equatable will help us to compare two instances of
// MyUser class, and we will not have to override == and hashCode.
class MyUser extends Equatable {
final String id;
final String name;
final String lastName;
final int age;
final String? image;
const MyUser({
required this.id,
required this.name,
required this.lastName,
required this.age,
this.image,
});
// When comparing two instances of MyUser class, we want to check
// that all the properties are the same, then we can say that
// the two instances are equals
@override
List<Object?> get props => [id, name, lastName, age, image];
// Helper function to convert this MyUser to a Map
Map<String, Object?> toFirebaseMap() {
return <String, Object?>{
'id': id,
'name': name,
'lastName': lastName,
'age': age,
'image': image,
};
}
// Helper function to convert a Map to an instance of MyUser
MyUser.fromFirebaseMap(Map<String, Object?> data)
: id = data['id'] as String,
name = data['name'] as String,
lastName = data['lastName'] as String,
age = data['age'] as int,
image = data['image'] as String?;
// Helper function that updates some properties of this instance,
// and returns a new updated instance of MyUser
MyUser copyWith({
String? id,
String? name,
String? lastName,
int? age,
String? image,
}) {
return MyUser(
id: id ?? this.id,
name: name ?? this.name,
lastName: lastName ?? this.lastName,
age: age ?? this.age,
image: image ?? this.image,
);
}
}
The Data Layer: FirebaseDataSource
The data layer will be split into two sub-layers, and the lowest layer is the FirebaseDataSource
class. This class will interact directly with Firebase and will be in charge of reading the list of users, saving new users, and deleting existing users.
class FirebaseDataSource {
// Helper function to get the currently authenticated user
User get currentUser {
final user = FirebaseAuth.instance.currentUser;
if (user == null) throw Exception('Not authenticated exception');
return user;
}
FirebaseFirestore get firestore => FirebaseFirestore.instance;
FirebaseStorage get storage => FirebaseStorage.instance;
// Generates and returns a new firestore id
String newId() {
return firestore.collection('tmp').doc().id;
}
// Read all documents from MyUser collection from the authenticated user
Stream<Iterable<MyUser>> getMyUsers() {
return firestore
.collection('user/${currentUser.uid}/myUsers')
.snapshots()
.map((it) => it.docs.map((e) => MyUser.fromFirebaseMap(e.data())));
}
// Creates or updates a document in myUser collection. If image is not null
// it will create or update the image in Firebase Storage
Future<void> saveMyUser(MyUser myUser, File? image) async {
final ref = firestore.doc('user/${currentUser.uid}/myUsers/${myUser.id}');
if (image != null) {
// Delete current image if exists
if (myUser.image != null) {
await storage.refFromURL(myUser.image!).delete();
}
final fileName = image.uri.pathSegments.last;
final imagePath = '${currentUser.uid}/myUsersImages/$fileName';
final storageRef = storage.ref(imagePath);
await storageRef.putFile(image);
final url = await storageRef.getDownloadURL();
myUser = myUser.copyWith(image: url);
}
await ref.set(myUser.toFirebaseMap(), SetOptions(merge: true));
}
// Deletes the MyUser document. Also will delete the
// image from Firebase Storage
Future<void> deleteMyUser(MyUser myUser) async {
final ref = firestore.doc('user/${currentUser.uid}/myUsers/${myUser.id}');
// Delete current image if exists
if (myUser.image != null) {
await storage.refFromURL(myUser.image!).delete();
}
await ref.delete();
}
}
Later on, we will use GetIt to inject an instance of the FirebaseDataSource
so repositories can use it.
The Data Layer: AuthRepository
The AuthRepository
is part of the data layer upper layer. On big applications, the repositories will wrap one or more data sources and combine the data into one.
The AuthRepository
class will help us to log out of the app and to know the current authentication status. Log-in will be handled directly by FirebaseUI, so the AuthRepository class will not have any function to handle it. Let's create an abstract class:
typedef UserUID = String;
abstract class AuthRepository {
Stream<UserUID?> get onAuthStateChanged;
Future<void> signOut();
}
Note
Note: Abstract classes are used to decouple our code from implementation-specific details. This can help in the future if we want to swap our real repository with a completely different implementation or we want to mock the repository in our tests
Now we are going to create the implementation:
class AuthRepositoryImp extends AuthRepository {
final _firebaseAuth = FirebaseAuth.instance;
@override
Stream<UserUID?> get onAuthStateChanged {
return _firebaseAuth.authStateChanges().asyncMap((user) => user?.uid);
}
@override
Future<void> signOut() {
return _firebaseAuth.signOut();
}
}
Later on, we will use GetIt to inject an instance of the AuthRepositoryImp
so cubits can use it.
The Data Layer: MyUserRepository
This class will help us to read, write and delete from our database. First, we create the abstract class to decouple the implementation details.
abstract class MyUserRepository {
String newId();
Stream<Iterable<MyUser>> getMyUsers();
Future<void> saveMyUser(MyUser myUser, File? image);
Future<void> deleteMyUser(MyUser myUser);
}
Remember that using an abstraction will give some flexibility to our code. If we want to switch Firebase for another service in the future, we only have to create a new implementation.
Now we create the implementation:
class MyUserRepositoryImp extends MyUserRepository {
final FirebaseDataSource _fDataSource = getIt();
@override
String newId() {
return _fDataSource.newId();
}
@override
Stream<Iterable<MyUser>> getMyUsers() {
return _fDataSource.getMyUsers();
}
@override
Future<void> saveMyUser(MyUser myUser, File? image) {
return _fDataSource.saveMyUser(myUser, image);
}
@override
Future<void> deleteMyUser(MyUser myUser) {
return _fDataSource.deleteMyUser(myUser);
}
}
Later on, we will use GetIt to inject an instance of the MyUserRepositoryImp
so cubits can use it.
GetIt: Injecting data layer instances
Now in the main.dart
file we are going to use GetIt to inject the FirebaseDataSource
, AuthRepository
and MyUserRepository
like this:
// Create a global instance of GetIt that can be used later to
// retrieve our injected instances
final getIt = GetIt.instance;
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize firebase
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
// Inject dependencies
await injectDependencies();
runApp(const MyApp());
}
// Helper function to inject dependencies
Future<void> injectDependencies() async {
// Inject the data source.
getIt.registerLazySingleton(() => FirebaseDataSource());
// Inject the Repositories. Note that the type is the abstract class
// and the injected instance is the implementation.
getIt.registerLazySingleton<AuthRepository>(() => AuthRepositoryImp());
getIt.registerLazySingleton<MyUserRepository>(() => MyUserRepositoryImp());
}
Using registerLazySingleton
will make the instances initialize only when we want to use them.
Now we can access any of the injected instances like this:
// Get an instance of the FirebaseDataSource
final FirebaseDataSource dataSource = getIt();
// Get an instance of the AuthRepository
final AuthRepository authRepo = getIt();
// Get an instance of the MyUserRepository
final MyUserRepository userRepo = getIt();
The code above can also be written like this:
// Get an instance of the FirebaseDataSource
final dataSource = getIt<FirebaseDataSource>();
// Get an instance of the AuthRepository
final authRepo = getIt<AuthRepository>();
// Get an instance of the MyUserRepository
final userRepo = getIt<MyUserRepository>();
Both ways to retrieve the injected dependencies are correct, so you can use the one you like.
Data Layer: Summary
We have completed the data layer. We created the class FirebaseDataSource
that will interact directly with Firebase. We also made two repositories, AuthRepository
and MyUserRepository,
that in bigger apps will handle complex data logic like combining data from different data sources.
We also learned how to decouple implementation details using abstract
classes. Not it is time to move to the next layer, the business layer.
Business Logic: AuthCubit
Can you guess what the job of the AuthCubit
class is? Let's take a look at the implementation:
// Enum with all possible authentication states.
enum AuthState {
initial,
signedOut,
signedIn,
}
// Extends Cubit and will emit states of type AuthState
class AuthCubit extends Cubit<AuthState> {
// Get the injected AuthRepository
final AuthRepository _authRepository = getIt();
StreamSubscription? _authSubscription;
AuthCubit() : super(AuthState.initial);
Future<void> init() async {
// Subscribe to listen for changes in the authentication state
_authSubscription = _authRepository.onAuthStateChanged.listen(_authStateChanged);
}
// Helper function that will emit the current authentication state
void _authStateChanged(String? userUID) {
userUID == null ? emit(AuthState.signedOut) : emit(AuthState.signedIn);
}
// Sign-out and immediately emits signedOut state
Future<void> signOut() async {
await _authRepository.signOut();
emit(AuthState.signedOut);
}
@override
Future<void> close() {
_authSubscription?.cancel();
return super.close();
}
}
Did you guess right? In this app, the AuthCubit
only has two jobs. The first one is to keep track of the current authentication state: AuthState.signedIn
or AuthState.signedOut
and to sign out the user.
Sign-in won't be done in this class because FlutterFire UI already handles all the flow for us.
Did you notice we also have the state AuthState.initial
? We have this state because the moment we launch the app, we do not know if we are signed in or signed out, so during those few milliseconds, we will be in the AuthState.initial
state.
Business Logic: HomeCubit
The HomeCubit
will keep the latest list of myUser
objects. Every time one new object is created, the list will be updated, and a new state will be emitted.
// Extends Cubit and will emit states of the type HomeState
class HomeCubit extends Cubit<HomeState> {
// Get the injected MyUserRepository
final MyUserRepository _userRepository = getIt();
StreamSubscription? _myUsersSubscription;
HomeCubit() : super(const HomeState());
Future<void> init() async {
// Subscribe to listen for changes in the myUser list
_myUsersSubscription = _userRepository.getMyUsers().listen(myUserListen);
}
// Every time the myUser list is updated, this function will be called
// with the latest data
void myUserListen(Iterable<MyUser> myUsers) async {
emit(HomeState(
isLoading: false,
myUsers: myUsers,
));
}
@override
Future<void> close() {
_myUsersSubscription?.cancel();
return super.close();
}
}
// Class that will hold the state of this Cubit
// Extending Equatable will help us to compare if two instances
// are the same without override == and hashCode
class HomeState extends Equatable {
final bool isLoading;
final Iterable<MyUser> myUsers;
const HomeState({
this.isLoading = true,
this.myUsers = const [],
});
@override
List<Object?> get props => [isLoading, myUsers];
}
Business Logic: EditMyUserCubit
The EditMyUserCubit
is the one that will help handle creating, deleting, and updating myUser
objects. Let's take a look at the implementation:
// Extends Cubit and will emit states of type EditMyUserState
class EditMyUserCubit extends Cubit<EditMyUserState> {
// Get the injected MyUserRepository
final MyUserRepository _userRepository = getIt();
// When we are editing an existing myUser _toEdit won't be null
MyUser? _toEdit;
EditMyUserCubit(this._toEdit) : super(const EditMyUserState());
// This function will be called from the presentation layer
// when an image is selected
void setImage(File? imageFile) async {
emit(state.copyWith(pickedImage: imageFile));
}
// This function will be called from the presentation layer
// when the user has to be saved
Future<void> saveMyUser(
String name,
String lastName,
int age,
) async {
emit(state.copyWith(isLoading: true));
// If we are editing, we use the existing id. Otherwise, create a new one.
final uid = _toEdit?.id ?? _userRepository.newId();
_toEdit = MyUser(
id: uid,
name: name,
lastName: lastName,
age: age,
image: _toEdit?.image);
await _userRepository.saveMyUser(_toEdit!, state.pickedImage);
emit(state.copyWith(isDone: true));
}
// This function will be called from the presentation layer
// when we want to delete the user
Future<void> deleteMyUser() async {
emit(state.copyWith(isLoading: true));
if (_toEdit != null) {
await _userRepository.deleteMyUser(_toEdit!);
}
emit(state.copyWith(isDone: true));
}
}
// Class that will hold the state of this Cubit
// Extending Equatable will help us to compare if two instances
// are the same without override == and hashCode
class EditMyUserState extends Equatable {
final File? pickedImage;
final bool isLoading;
// In the presentation layer, we will check the value of isDone.
// When it is true, we will navigate to the previous page
final bool isDone;
const EditMyUserState({
this.pickedImage,
this.isLoading = false,
this.isDone = false,
});
@override
List<Object?> get props => [pickedImage?.path, isLoading, isDone];
// Helper function that updates some properties of this object,
// and returns a new updated instance of EditMyUserState
EditMyUserState copyWith({
File? pickedImage,
bool? isLoading,
bool? isDone,
}) {
return EditMyUserState(
pickedImage: pickedImage ?? this.pickedImage,
isLoading: isLoading ?? this.isLoading,
isDone: isDone ?? this.isDone,
);
}
}
Business Logic: Summary
The work on the Business Logic layer is done. We have created three cubits that will help with the authentication, fetching the list of the users and creating, updating, and deleting myUser
objects.
The AuthCubit
will be injected on the very top of the widget tree so every child can access the current authentication state. The HomeCubit
and EditMyUserCubit
will be scoped to their respective screens.
Presentation Layer: Navigation and initial flow: Home Screen or Intro Screen
When the app is launched, we have to decide if we are going to show the Home Screen or the Intro Page
The way we will do it is that as soon as the app is launched, we will show a Splash Screen, and also, we will be checking the current authentication state so we can decide where we will navigate.
1- The first thing to do is initialize the AuthCubit
so we can know the authentication state. So will go to the main.dart
file and do the following modification:
runApp(
// AuthCubit will be at the top of the widget tree
BlocProvider(
create: (_) => AuthCubit()..init(),
child: const MyApp(),
),
);
This will put the AuthCubit at the very top of the widget tree, allowing us to access that authentication state in any app widget.
2- We will now create all the possible routes our app will handle. So I will make a new file routes.dart
with four routes splash,
intro,
home,
and editUser
:
class Routes {
static const splash = '/';
static const intro = '/intro';
static const home = '/home';
static const editUser = '/editUser';
static Route routes(RouteSettings settings) {
// Helper nested function.
MaterialPageRoute _buildRoute(Widget widget) {
return MaterialPageRoute(builder: (_) => widget, settings: settings);
}
switch (settings.name) {
case splash:
return _buildRoute(const SplashScreen());
case intro:
return _buildRoute(const IntroScreen());
case home:
return _buildRoute(const HomeScreen());
case editUser:
return _buildRoute(const EditMyUserScreen());
default:
throw Exception('Route does not exists');
}
}
}
Did you notice that the initial route is the Splash Screen?
3- Use a BlocListener
to listen for the authentication state changes and decide where we should navigate. This time we go to the app.dart
file and update the code like this:
final _navigatorKey = GlobalKey<NavigatorState>();
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
// Listen for authentication state changes and
// navigate to the intro or home screens
return BlocListener<AuthCubit, AuthState>(
listener: (context, state) {
if (state == AuthState.signedOut) {
_navigatorKey.currentState
?.pushNamedAndRemoveUntil(Routes.intro, (r) => false);
} else if (state == AuthState.signedIn) {
_navigatorKey.currentState
?.pushNamedAndRemoveUntil(Routes.home, (r) => false);
}
},
child: MaterialApp(
navigatorKey: _navigatorKey,
title: 'Authentication Flow',
onGenerateRoute: Routes.routes,
),
);
}
}
One thing we should not miss is that the BlocListener
will be active during the entire life cycle of the app, so if the user sign-outs, the listener will trigger, and it will navigate to the Intro Screen.
Presentation Layer: Splash Screen
The Splash Screen is to let the user know we are loading the app. Most of the time, this screen will be shown so fast the user may not even notice it. The UI will look like this:
The splash screen
Let's take a look at the code implementation:
class SplashScreen extends StatelessWidget {
const SplashScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
CircularProgressIndicator(),
SizedBox(height: 24),
Text('Loading...', style: TextStyle(fontSize: 24)),
],
),
),
);
}
}
Presentation Layer: Intro and Login Screens
The Intro and Login will be inside a PageView
. We can show a description on the Intro page, and the Login will be created using FlutterFire UI. The final result will look like this:
Let's create a new file, intro_screen.dart
and add some code to it:
// Replace with your client id
const googleClientId = 'xxxxxxxx';
// Replace with your client id
const facebookClientId = 'xxxxxx';
class IntroScreen extends StatelessWidget {
const IntroScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Welcome'),
),
body: _IntroPager(),
);
}
}
class _IntroPager extends StatelessWidget {
final String exampleText = 'Lorem ipsum dolor sit amet, consecrated advising elit, '
'sed do eiusmod tempor incididunt ut labore et '
'dolore magna aliqua. Ut enim ad minim veniam.';
@override
Widget build(BuildContext context) {
return PageIndicatorContainer(
align: IndicatorAlign.bottom,
length: 4,
indicatorSpace: 12,
indicatorColor: Colors.grey,
indicatorSelectorColor: Colors.black,
child: PageView(
children: <Widget>[
_DescriptionPage(
text: exampleText,
imagePath: 'assets/intro_1.png',
),
_DescriptionPage(
text: exampleText,
imagePath: 'assets/intro_2.png',
),
_DescriptionPage(
text: exampleText,
imagePath: 'assets/intro_3.png',
),
_LoginPage(),
],
),
);
}
}
class _DescriptionPage extends StatelessWidget {
final String text;
final String imagePath;
const _DescriptionPage({
super.key,
required this.text,
required this.imagePath,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Image.asset(
imagePath,
width: 200,
height: 200,
),
Expanded(
child: Container(
alignment: Alignment.center,
child: Text(
text,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 24.0, fontWeight: FontWeight.bold),
),
),
),
],
),
);
}
}
class _LoginPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return const SignInScreen(
providerConfigs: [
GoogleProviderConfiguration(clientId: googleClientId),
FacebookProviderConfiguration(clientId: facebookClientId),
EmailProviderConfiguration(),
],
);
}
}
If you are a good observer, you have already noticed that there is no code to handle navigation in the intro_screen.dart
file. Do you know why? Remember that we already take care of the Home Screen navigation in the app.dart
file.
Presentation Layer: Home Screen
The Home Screen may look very simple, but many things are happening here. Take a look for one minute at the UI. Can you find all the things the user can do?
The home screen
Did you find all the things the user can do? Let's see, the user can:
- Sign out from the app
- Can see the list of users
- Can tap a row in the list to edit the user
- Can tap in the + floating button to create a new user
Let's create a new file, home_screen.dart
and add some code to it:
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Home screen'),
actions: [
IconButton(
onPressed: () {
// Get the instance of AuthCubit and signOut
final authCubit = context.read<AuthCubit>();
authCubit.signOut();
},
icon: const Icon(Icons.logout),
),
],
),
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () {
// Navigate to the editUser route without arguments
// to create a new myUser
Navigator.pushNamed(context, Routes.editUser);
},
),
// Use BlocProvider to pass the HomeCubit to the widget tree
body: BlocProvider(
create: (context) => HomeCubit()..init(),
child: BlocBuilder<HomeCubit, HomeState>(
builder: (context, state) {
return ListView.builder(
itemCount: state.myUsers.length,
itemBuilder: (context, index) {
final myUser = state.myUsers.elementAt(index);
return Card(
child: ListTile(
onTap: () {
// Navigate to the editUser route with arguments
// to edit the tapped myUser
Navigator.pushNamed(context, Routes.editUser, arguments: myUser);
},
leading: SizedBox(
height: 45,
width: 45,
child: CustomImage(imageUrl: myUser.image),
),
title: Text('${myUser.name} ${myUser.lastName}'),
subtitle: Text('Age: ${myUser.age}'),
),
);
},
);
},
),
),
);
}
}
Presentation Layer: Edit and Create MyUser Screen
Editing and creating myUser
objects will be done in the same EditMyUserScreen
class. The user will be able to:
- Choose a profile image
- Edit name, last name, and age
- Save new or existing user
- Delete existing users
The UI will look like this:
The edit and create user screen
Let's create a new file, edit_my_user_screen.dart
and add some code to it:
class EditMyUserScreen extends StatelessWidget {
const EditMyUserScreen({super.key});
@override
Widget build(BuildContext context) {
// If userToEdit is not null it means we are editing
final userToEdit = ModalRoute.of(context)?.settings.arguments as MyUser?;
return BlocProvider(
create: (context) => EditMyUserCubit(userToEdit),
child: Scaffold(
appBar: AppBar(
title: const Text('Edit or create user'),
actions: [
Builder(builder: (context) {
// If we are creating a new myUser do not show the
// delete button
return Visibility(
visible: userToEdit != null,
child: IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
context.read<EditMyUserCubit>().deleteMyUser();
},
),
);
}),
],
),
body: BlocConsumer<EditMyUserCubit, EditMyUserState>(
listener: (context, state) {
if (state.isDone) {
// When isDone is true we navigate to the previous screen/route
Navigator.of(context).pop();
}
},
builder: (_, state) {
return Stack(
children: [
_MyUserSection(
user: userToEdit,
pickedImage: state.pickedImage,
isSaving: state.isLoading,
),
if (state.isLoading)
Container(
color: Colors.black12,
child: const Center(
child: CircularProgressIndicator(),
),
),
],
);
},
),
),
);
}
}
class _MyUserSection extends StatefulWidget {
final MyUser? user;
final File? pickedImage;
final bool isSaving;
const _MyUserSection({this.user, this.pickedImage, this.isSaving = false});
@override
_MyUserSectionState createState() => _MyUserSectionState();
}
class _MyUserSectionState extends State<_MyUserSection> {
final _nameController = TextEditingController();
final _lastNameController = TextEditingController();
final _ageController = TextEditingController();
final picker = ImagePicker();
@override
void initState() {
_nameController.text = widget.user?.name ?? '';
_lastNameController.text = widget.user?.lastName ?? '';
_ageController.text = widget.user?.age.toString() ?? '';
super.initState();
}
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
child: Container(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
GestureDetector(
onTap: () async {
final editCubit = context.read<EditMyUserCubit>();
final pickedImage =
await picker.pickImage(source: ImageSource.gallery);
if (pickedImage != null) {
editCubit.setImage(File(pickedImage.path));
}
},
child: Center(
child: ClipOval(
child: SizedBox(
width: 150,
height: 150,
child: CustomImage(
imageFile: widget.pickedImage,
imageUrl: widget.user?.image,
),
),
),
),
),
const SizedBox(height: 8),
TextField(
controller: _nameController,
decoration: const InputDecoration(labelText: 'Name'),
),
const SizedBox(height: 8),
TextField(
controller: _lastNameController,
decoration: const InputDecoration(labelText: 'Last Name'),
),
const SizedBox(height: 8),
TextField(
controller: _ageController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Age'),
),
const SizedBox(height: 8),
// When isSaving is true we disable the button
ElevatedButton(
onPressed: widget.isSaving
? null
: () {
context.read<EditMyUserCubit>().saveMyUser(
_nameController.text,
_lastNameController.text,
int.tryParse(_ageController.text) ?? 0,
);
},
child: const Text('Save'),
),
],
),
),
);
}
}
Finally, we created the widgets needed for the UI of our app.
Cubit and Widget Tests
I've written some cubit and widget tests for this article. They are located in the test folder. You can download the source code from GitHub and run the tests by yourself.
Conclusion
Now we have a working app that can write, read and delete documents into Firebase Firestore and also write, read and delete files into Firebase Storage.
We learned a simple architecture/pattern that allows us to split our code into several layers and create a code base that is easier to maintain and test.
Please let me know your questions or comments, and if you liked this article, please help me share it. Thank you!