Skip to main content

CRUD APP using Firebase Authentication, Firestore, Storage & 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

Final result. Login, home, and edit screens

info

You can find the source code on GitHub

Firebase UI will handle the authentication flow, so we will not have to build it from scratch.

Some of the packages that we will use are:

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: the data layer, business logic and presentation layer. Plus an external data source that in this case will be Firebase.

External Data Sources

The external data sources are all the different sources from which an application can obtain information, such as a database, web API, local files, device sensors, etc.

Data Layer

Manages interactions with data sources and allows other parts of the application to access data in an organized and secure manner. This layer is further divided into data sources and repositories:

Data Source

The data provider's responsibility is to provide raw data. The data provider should be generic and versatile.

Repository

The repository layer is a wrapper around one or more data sources with which our business layer communicates

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.

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.

Presentation Layer

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.

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

External Data Source

In our application, the external data sources are the services provided by Firebase: Firebase Authentication, Cloud Storage, and Firestore.

Initially, we are going to focus on the data that we will store in Firestore. We will create the class MyUser that will have all the properties that we want to store in Firestore. For example, the name, the lastname, etc.:

lib/src/model/my_user.dart
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,
});


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,
);
}
}
tip

Did you notice we are using Equatable in the class MyUser:

  • Equatable: It simplifies comparing two objects of the same class without overriding == and hashCode. You can learn more in this article.

The Data Layer

FirebaseDataSource

The data layer will be split into two the data sources and repositories, 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.

lib/src/data_source/firebase_data_source.dart
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() => 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();
}
}
note

Later, we will use dependency injection with GetIt to inject an instance of the class FirebaseDataSource so repositories can have access to it.

AuthRepository

The class 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. FirebaseUI will handle log-in directly, so the AuthRepository class will not have any function to handle it. Let's create an abstract class:

lib/src/repository/auth_repository.dart
typedef UserUID = String;

abstract class AuthRepository {
Stream<UserUID?> get onAuthStateChanged;

Future<void> signOut();
}
tip

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:

lib/src/repository/implementation/auth_repository.dart
class AuthRepositoryImp extends AuthRepository {
final _firebaseAuth = FirebaseAuth.instance;


Stream<UserUID?> get onAuthStateChanged =>
_firebaseAuth.authStateChanges().asyncMap((user) => user?.uid);


Future<void> signOut() => _firebaseAuth.signOut();
}
note

Later, we will use dependency injection with GetIt to inject an instance of the class AuthRepositoryImp so cubits can have access to it.

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.

lib/src/repository/my_user_repository.dart
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 let's create the implementation:

lib/src/repository/implementation/my_user_repository.dart
class MyUserRepositoryImp extends MyUserRepository {
final FirebaseDataSource _fDataSource = getIt();


String newId() {
return _fDataSource.newId();
}


Stream<Iterable<MyUser>> getMyUsers() {
return _fDataSource.getMyUsers();
}


Future<void> saveMyUser(MyUser myUser, File? image) {
return _fDataSource.saveMyUser(myUser, image);
}


Future<void> deleteMyUser(MyUser myUser) {
return _fDataSource.deleteMyUser(myUser);
}
}
note

Later, we will use dependency injection with GetIt to inject an instance of the class MyUserRepositoryImp so cubits can have access to it.

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.

Dependency Injection

GetIt

GetIt is a service locator package that helps us adhere to the Dependency Inversion Principle, which aims to reduce coupling between software components and promote flexibility and ease of maintenance.

In the main.dart file we are going to use GetIt to inject the classes FirebaseDataSource, AuthRepository and MyUserRepository like this:

lib/main.dart
// Create a global instance of GetIt that can be used
// later to retrieve the 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());
}
tip

Using registerLazySingleton will make the instances initialize only when we want to use them.

Ton access any of the injected instances we can do it 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.

Business Logic

AuthCubit

Can you guess what the job of the AuthCubit class is? Let's take a look at the implementation:

lib/src/cubit/auth_cubit.dart
// 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);
}


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 Firebase 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.

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.

lib/src/cubit/home_cubit.dart
// 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,
));
}


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 [],
});


List<Object?> get props => [isLoading, myUsers];
}

EditMyUserCubit

The EditMyUserCubit is the one that will help handle creating, deleting, and updating myUser objects. Let's take a look at the implementation:

lib/src/cubit/edit_my_user_cubit.dart
// 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,
});


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,
);
}
}

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:

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. Initialize AuthCubit at the top of the widget tree

The first thing to do is initializing the AuthCubit so we can know the authentication state. So will go to the main.dart file and do the following modification:

lib/main.dart
  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. Create all routes supported by the app

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:

lib/src/navigation/routes.dart
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. Navigate to the right page using BlocListener

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:

lib/src/app.dart
final _navigatorKey = GlobalKey<NavigatorState>();

class MyApp extends StatelessWidget {
const MyApp({super.key});


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.

The 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

The Splash Screen

Let's take a look at the code implementation:

lib/src/ui/splash_screen.dart
class SplashScreen extends StatelessWidget {
const SplashScreen({super.key});


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)),
],
),
),
);
}
}

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 Firebase UI. The final result will look like this:

Intro and Login Screens

Intro and Login Screens

Let's create a new file, intro_screen.dart and add some code to it:

lib/src/ui/intro_screen.dart
// Replace with your client id
final googleClientId = kIsWeb || Platform.isAndroid
? 'your-android-and-web-client-id'
: 'your-ios-client-id';

// Replace with your client id
const facebookClientId = 'your-facebook-client-id';

class IntroScreen extends StatelessWidget {
const IntroScreen({super.key});


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.';


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({
required this.text,
required this.imagePath,
});


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 {

Widget build(BuildContext context) {
return SignInScreen(
providers: [
GoogleProvider(clientId: googleClientId),
FacebookProvider(clientId: facebookClientId),
EmailAuthProvider(),
],
);
}
}

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.

Home Screen

The Home Screen may look basic, 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

The Home Screen

Did you find all the things the user can do? Let's see, the user can:

  • Sign out from the app
  • See the list of users
  • Tap a row in the list to edit the user
  • 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:

lib/src/ui/home_screen.dart
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});


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}'),
),
);
},
);
},
),
),
);
}
}

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

The edit and create user screen

Let's create a new file, edit_my_user_screen.dart and add some code to it:

lib/src/ui/edit_my_user_screen.dart
class EditMyUserScreen extends StatelessWidget {
const EditMyUserScreen({super.key});


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});


_MyUserSectionState createState() => _MyUserSectionState();
}

class _MyUserSectionState extends State<_MyUserSection> {
final _nameController = TextEditingController();
final _lastNameController = TextEditingController();
final _ageController = TextEditingController();

final picker = ImagePicker();


void initState() {
_nameController.text = widget.user?.name ?? '';
_lastNameController.text = widget.user?.lastName ?? '';
_ageController.text = widget.user?.age.toString() ?? '';
super.initState();
}


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 source code test folder that you can download from GitHub. Try to 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 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!