Аутентификация пользователей в приложении Flutter с помощью Firebase. Используйте функции Firebase Functions и коллекции Firestore для хранения информации о пользователях.
- О сайте
- Аутентификация с использованием электронной почты и пароля
- Перенаправление маршрутизатора по состоянию аутентификации
- Аутентификация пользователя Firebase с помощью электронной почты и пароля
- Импортируйте следующие файлы
- Создание класса и инстанцирование
- Регистрация с помощью электронной почты и пароля
- Вход в систему с помощью электронной почты и пароля
- Метод выхода из системы
- Собираем все части вместе
- Добавление провайдера в дерево виджетов
- Обработка отправки формы по щелчку
- Создание провайдера
- Назначение обработчика отправки
- Виджет AuthForm
- Вызов выхода из системы
- Функции Firebase: Триггеры облачного хранилища
- Резюме
- Показать поддержку
О сайте
Здравствуйте! Добро пожаловать на 77-й блог из серии “Самоучитель по разработке приложений Flutter”. Я Нибеш Кхадка из Khadka’s Coding Lounge. В последних двух блогах мы создали пользовательский интерфейс входа/регистрации и установили соединение нашего проекта Flutter с проектом Firebase. Помимо них, мы также уже сделали Splash Screen, некоторые глобальные виджеты, такие как панель приложений и нижняя панель навигации, а также внедрили глобальную тему для создаваемого нами приложения.
К концу блога мы сможем аутентифицировать пользователей в нашем приложении. До этого вы можете ознакомиться с прогрессом, достигнутым на данный момент, в этой папке репозитория.
Аутентификация с использованием электронной почты и пароля
Перенаправление маршрутизатора по состоянию аутентификации
Наш поток пользовательских экранов таков, что после входа в приложение мы проверяем, аутентифицирован пользователь или нет. Если нет, переходим к экрану аутентификации, в противном случае переходим на главную страницу. Поэтому мы должны указать маршрутизатору перенаправлять пользователя при изменении статуса аутентификации.
Давайте сделаем это в нашем файле app_router.dart. Нам придется внести изменения в метод перенаправления Go Router.
...
redirect: (state) {
....
// define the named path of auth screen
// #1
final String authPath = state.namedLocation(APP_PAGE.auth.routeName);
// Checking if current path is auth or not
// # 2
bool isAuthenticating = state.subloc == authPath;
// Check if user is loggedin or not based on userLog Status
// #3
bool isLoggedIn =
FirebaseAuth.instance.currentUser != null ? true : false;
print("isLoggedIn is: $isLoggedIn");
if (toOnboard) {
// return null if the current location is already OnboardScreen to prevent looping
return isOnboarding ? null : onboardPath;
}
// only authenticate if a user is not logged in
// #4
else if (!isLoggedIn) {
return isAuthenticating ? null : authPath; // #5
}
// returning null will tell the router to don't mind redirecting the section
return null;
});
Итак, что мы сделали:
- Мы определили именованный путь для экрана аутентификации.
- Аналогично, булево значение для проверки, находится ли приложение на пути к экрану аутентификации.
- FirebaseAuth.instance.currentUser возвращает статус текущего пользователя: null, если он отсутствует (вышел из системы), true, если вошел.
- Если пользователь отсутствует, перенаправьте его с того маршрута, на котором вы сейчас находитесь, на маршрут аутентификации.
- Если текущий маршрут уже не является маршрутом аутентификации, то возвращается null. Если не проверять текущий маршрут, то маршрутизатор может попасть в бесконечный цикл.
Аутентификация пользователя Firebase с помощью электронной почты и пароля
Мы используем локальный эмулятор. Но если вы используете Firebase Cloud, то сначала вам нужно перейти в консоль firebase вашего проекта, затем включить Email/Password SignIn в Authentication.
Теперь в файле auth_providers.dart из screens/auth/providers мы добавим функции аутентификации.
Импортируйте следующие файлы
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:temple/globals/settings/router/utils/router_utils.dart';
Создание класса и инстанцирование
Давайте создадим класс AuthStateProvider и инстанцируем FirebaseAuth.
class AuthStateProvider with ChangeNotifier {
FirebaseAuth authInstance = FirebaseAuth.instance;
}
Регистрация с помощью электронной почты и пароля
Напишите метод регистрации.
// Our Function will take email,password, username and buildcontext
// #1
void register(String email, String password, String username,
BuildContext context) async {
try {
// Get back usercredential future from createUserWithEmailAndPassword method
// # 2
UserCredential userCred = await authInstance
.createUserWithEmailAndPassword(email: email, password: password);
// Save username name
await userCred.user!.updateDisplayName(username);
// After that access "users" Firestore in firestore and save username, email and userLocation
// # 3
await FirebaseFirestore.instance
.collection('users')
.doc(userCred.user!.uid)
.set(
{
'username': username,
'email': email,
'userLocation': null,
},
);
// if everything goes well user will be registered and logged in
// now go to the homepage
// #4
GoRouter.of(context).goNamed(APP_PAGE.home.routeName);
} on FirebaseAuthException catch (e) {
// In case of error
// if email already exists
// # 5
if (e.code == "email-already-in-use") {
print("The account with this email already exists.");
}
if (e.code == 'weak-password') {
// If password is too weak
// #6
print("Password is too weak.");
}
} catch (e) {
// For anything else
// #6
print("Something went wrong please try again.");
}
// notify the listeneres
notifyListeners();
}
Давайте рассмотрим детали:
- Наша функция будет принимать адрес электронной почты, пароль, имя пользователя и BuildContext. Контекст сборки понадобится нам для маршрутизации.
- Мы используем метод createUserWithEmailAndPassword, доступный в FlutterFire.
- После регистрации пользователя мы также запишем новый документ в коллекцию ‘users’ на Firestore . Пока что игнорируйте поле userLocation. Мы разберемся с этим в последующих частях.
-
Если операция прошла успешно, то перейдите на главную страницу. Firebase автоматически регистрирует новых пользователей, поэтому нам не нужно делать это самим. Теперь, когда currentUser больше не является null, маршрутизатор перенаправит пользователя на домашнюю страницу.
-
(5 & 6) В случае ошибок отправьте пользователю соответствующее сообщение. Сейчас мы просто печатаем сообщение. Позже мы реализуем панель закусок для отображения сообщений на экране приложения.
Вход в систему с помощью электронной почты и пароля
Теперь, когда мы сделали функцию регистрации, давайте сделаем и функцию входа в систему.
// Our Function will take email, password and build context
void login(String email, String password, BuildContext context) async {
try {
// try signing in
# 1
UserCredential userCred = await authInstance.signInWithEmailAndPassword(
email: email, password: password);
// if succesfull leave auth screen and go to homepage
GoRouter.of(context).goNamed(APP_PAGE.home.routeName);
} on FirebaseAuthException catch (e) {
// On error
// If user is not found
if (e.code == 'user-not-found') {
print("No user found for that email.");
}
// If password is wrong
if (e.code == 'wrong-password') {
print("Wrong password.");
}
} catch (e) {
print("Something went wrong please try again");
}
// notify the listeners.
notifyListeners();
}
Мы используем метод регистрации из FlutterFire. Все остальное – то же самое, что и в методе регистрации.
Метод выхода из системы
Выход из системы – это очень простой и базовый метод.
void logOut() async {
await authInstance.signOut();
notifyListeners();
}
Собираем все части вместе
Теперь наш класс AuthStateProvider выглядит следующим образом.
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:temple/globals/settings/router/utils/router_utils.dart';
class AuthStateProvider with ChangeNotifier {
FirebaseAuth authInstance = FirebaseAuth.instance;
// Our Function will take email,password, username and buildcontext
void register(String email, String password, String username,
BuildContext context) async {
try {
// Get back usercredential future from createUserWithEmailAndPassword method
UserCredential userCred = await authInstance
.createUserWithEmailAndPassword(email: email, password: password);
// Save username name
await userCred.user!.updateDisplayName(username);
// After that access "users" Firestore in firestore and save username, email and userLocation
await FirebaseFirestore.instance
.collection('users')
.doc(userCred.user!.uid)
.set(
{
'username': username,
'email': email,
'userLocation': null,
},
);
// if everything goes well user will be registered and logged in
// now go to the homepage
GoRouter.of(context).goNamed(APP_PAGE.home.routeName);
} on FirebaseAuthException catch (e) {
// In case of error
// if email already exists
if (e.code == "email-already-in-use") {
print("The account with this email already exists.");
}
if (e.code == 'weak-password') {
// If password is too weak
print("Password is too weak.");
}
} catch (e) {
// For anything else
print("Something went wrong please try again.");
}
// notify listeneres
notifyListeners();
}
// Our Function will take email, password and build context
void login(String email, String password, BuildContext context) async {
try {
// try signing in
UserCredential userCred = await authInstance.signInWithEmailAndPassword(
email: email, password: password);
// if succesfull leave auth screen and go to homepage
GoRouter.of(context).goNamed(APP_PAGE.home.routeName);
} on FirebaseAuthException catch (e) {
// On error
// If user is not found
if (e.code == 'user-not-found') {
print("No user found for that email.");
}
// If password is wrong
if (e.code == 'wrong-password') {
print("Wrong password.");
}
} catch (e) {
print("Something went wrong please try again");
}
// notify the listeners.
notifyListeners();
}
void logOut() async {
await authInstance.signOut();
notifyListeners();
}
}
Добавление провайдера в дерево виджетов
Наша первая бета-версия функций аутентификации готова к тестированию. Итак, давайте сначала добавим нашего провайдера в дерево виджетов с помощью MultipleProviders.
app.dart
providers: [
ChangeNotifierProvider(create: (context) => AppStateProvider()),
// Add authStateProvider
ChangeNotifierProvider(create: (context) => AuthStateProvider()),
// Remove previous Provider call and create new proxyprovider that depends on AppStateProvider
ProxyProvider<AppStateProvider, AppRouter>(
update: (context, appStateProvider, _) => AppRouter(
appStateProvider: appStateProvider,
prefs: widget.prefs,
))
],
Обработка отправки формы по щелчку
Теперь перейдем к файлу auth_form_widget.dart в** lib/screen/auth/widgets/ . Здесь нам нужно написать функцию, которая будет срабатывать при нажатии на кнопку регистрации/сигнала. Мы назовем эту функцию **_submitForm(). Добавьте эту функцию сразу после метода msgPopUp().
// Submit form will take AuthStateProvider, and BuildContext
// #1
void _submitForm(
AuthStateProvider authStateProvider, BuildContext context) async {
// Check if the form and its input are valid
// #2
final isValid = _formKey.currentState!.validate();
// Trim the inputs to remove extra spaces around them
// #3
String username = usernameController.text.trim();
String email = emailController.text.trim();
String password = passwordController.text.trim();
// if the form is valid
// #4
if (isValid) {
// Save current state if form is valid
_formKey.currentState!.save();
// Try Sign In Or Register baed on if its register Auth Mode or not
// #5
if (registerAuthMode) {
authStateProvider.register(email, password, username, context);
}
} else {
authStateProvider.login(email, password, context);
}
}
Давайте пройдемся по деталям.
- Наша функция будет принимать в качестве аргументов AuthStateProvider & Build Context.
- Мы можем проверить, действительна ли форма, с помощью formKey.currentState!.validate().
- Обрежем вводимые данные, чтобы удалить лишние пробелы, если они есть.
- Если форма/ввод корректны, переходим к аутентификации.
- В зависимости от authMode мы либо зарегистрируем, либо зарегистрируем пользователя.
Создание провайдера
Внутри BuildContext сначала вызовем AuthStateProvider.
@override
Widget build(BuildContext context) {
// Instantiate AuthStateProvider
final AuthStateProvider authStateProvider = Provider.of<AuthStateProvider>(context);
Назначение обработчика отправки
Давайте спустимся вниз, где находится наша единственная кнопка ElevatedButton, и назначим метод _submitForm.
ElevatedButton(
onPressed: () {
// call _submitForm on tap
_submitForm(authStateProvider, context);
},
child: Text(registerAuthMode ? 'Register' : 'Sign In'),
style: ButtonStyle(
elevation: MaterialStateProperty.all(8.0),
),
),
Виджет AuthForm
Теперь наш auth_form_widget.dart выглядит следующим образом.
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:temple/screens/auth/providers/auth_provider.dart';
import 'package:temple/screens/auth/utils/auth_validators.dart';
import 'package:temple/screens/auth/widgets/text_from_widget.dart';
class AuthFormWidget extends StatefulWidget {
const AuthFormWidget({Key? key}) : super(key: key);
@override
State<AuthFormWidget> createState() => _AuthFormWidgetState();
}
class _AuthFormWidgetState extends State<AuthFormWidget> {
// Define Form key
final _formKey = GlobalKey<FormState>();
// Instantiate validator
final AuthValidators authValidator = AuthValidators();
// controllers
late TextEditingController emailController;
late TextEditingController usernameController;
late TextEditingController passwordController;
late TextEditingController confirmPasswordController;
// create focus nodes
late FocusNode emailFocusNode;
late FocusNode usernameFocusNode;
late FocusNode passwordFocusNode;
late FocusNode confirmPasswordFocusNode;
// to obscure text default value is false
bool obscureText = true;
// This will require to toggle between register and sigin in mode
bool registerAuthMode = false;
// Instantiate all the *text editing controllers* and focus nodes on *initState* function
@override
void initState() {
super.initState();
emailController = TextEditingController();
usernameController = TextEditingController();
passwordController = TextEditingController();
confirmPasswordController = TextEditingController();
emailFocusNode = FocusNode();
usernameFocusNode = FocusNode();
passwordFocusNode = FocusNode();
confirmPasswordFocusNode = FocusNode();
}
// These all need to be disposed of once done so let's do that as well.
@override
void dispose() {
super.dispose();
emailController.dispose();
usernameController.dispose();
passwordController.dispose();
confirmPasswordController.dispose();
emailFocusNode.dispose();
usernameFocusNode.dispose();
passwordFocusNode.dispose();
confirmPasswordFocusNode.dispose();
}
// Create a function that'll toggle the password's visibility on the relevant icon tap.
void toggleObscureText() {
setState(() {
obscureText = !obscureText;
});
}
// Let's create a snack bar to pop info on various circumstances.
// Create a scaffold messanger
SnackBar msgPopUp(msg) {
return SnackBar(
content: Text(
msg,
textAlign: TextAlign.center,
));
}
// Submit form will take AuthStateProvider, and BuildContext
void _submitForm(
AuthStateProvider authStateProvider, BuildContext context) async {
// Check if the form and its input are valid
final isValid = _formKey.currentState!.validate();
// Trim the inputs to remove extra spaces around them
String username = usernameController.text.trim();
String email = emailController.text.trim();
String password = passwordController.text.trim();
// if the form is valid
if (isValid) {
// Save current state if form is valid
_formKey.currentState!.save();
// Try Sigin Or Register baed on if its register Auth Mode or not
if (registerAuthMode) {
authStateProvider.register(email, password, username, context);
}
} else {
authStateProvider.login(email, password, context);
}
}
@override
Widget build(BuildContext context) {
final AuthStateProvider authStateProvider =
Provider.of<AuthStateProvider>(context);
return Padding(
padding: const EdgeInsets.all(8),
child: Form(
key: _formKey,
child: Column(
children: [
// Email
DynamicInputWidget(
controller: emailController,
obscureText: false,
focusNode: emailFocusNode,
toggleObscureText: null,
validator: authValidator.emailValidator,
prefIcon: const Icon(Icons.mail),
labelText: "Enter Email Address",
textInputAction: TextInputAction.next,
isNonPasswordField: true,
),
const SizedBox(
height: 20,
),
// Username
AnimatedContainer(
duration: const Duration(milliseconds: 500),
height: registerAuthMode ? 65 : 0,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: registerAuthMode ? 1 : 0,
child: DynamicInputWidget(
controller: usernameController,
obscureText: false,
focusNode: usernameFocusNode,
toggleObscureText: null,
validator: null,
prefIcon: const Icon(Icons.person),
labelText: "Enter Username(Optional)",
textInputAction: TextInputAction.next,
isNonPasswordField: true,
),
),
),
AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: registerAuthMode ? 1 : 0,
child: const SizedBox(
height: 20,
),
),
DynamicInputWidget(
controller: passwordController,
labelText: "Enter Password",
obscureText: obscureText,
focusNode: passwordFocusNode,
toggleObscureText: toggleObscureText,
validator: authValidator.passwordVlidator,
prefIcon: const Icon(Icons.password),
textInputAction: registerAuthMode
? TextInputAction.next
: TextInputAction.done,
isNonPasswordField: false,
),
const SizedBox(
height: 20,
),
AnimatedContainer(
duration: const Duration(milliseconds: 500),
height: registerAuthMode ? 65 : 0,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: registerAuthMode ? 1 : 0,
child: DynamicInputWidget(
controller: confirmPasswordController,
focusNode: confirmPasswordFocusNode,
isNonPasswordField: false,
labelText: "Confirm Password",
obscureText: obscureText,
prefIcon: const Icon(Icons.password),
textInputAction: TextInputAction.done,
toggleObscureText: toggleObscureText,
validator: (val) => authValidator.confirmPasswordValidator(
val, passwordController.text),
),
),
),
const SizedBox(
height: 20,
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () {},
child: const Text('Cancel'),
),
const SizedBox(
width: 20,
),
ElevatedButton(
onPressed: () {
// call _submitForm on tap
_submitForm(authStateProvider, context);
},
child: Text(registerAuthMode ? 'Register' : 'Sign In'),
style: ButtonStyle(
elevation: MaterialStateProperty.all(8.0),
),
),
],
),
const SizedBox(
height: 20,
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(registerAuthMode
? "Already Have an account?"
: "Don't have an account yet?"),
TextButton(
onPressed: () =>
setState(() => registerAuthMode = !registerAuthMode),
child: Text(registerAuthMode ? "Sign In" : "Regsiter"),
)
],
)
],
),
),
);
}
}
Вызов выхода из системы
Мы не добавили метод выхода из системы. Давайте сделаем это в файле user_drawer.dart в lib/globals/widgets/user_drawer/. Также пока мы здесь, давайте удалим временный маршрут аутентификации.
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:temple/globals/settings/router/utils/router_utils.dart';
import 'package:temple/screens/auth/providers/auth_provider.dart';
class UserDrawer extends StatefulWidget {
const UserDrawer({Key? key}) : super(key: key);
@override
_UserDrawerState createState() => _UserDrawerState();
}
class _UserDrawerState extends State<UserDrawer> {
@override
Widget build(BuildContext context) {
return AlertDialog(
backgroundColor: Theme.of(context).colorScheme.primary,
actionsPadding: EdgeInsets.zero,
scrollable: true,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
title: Text(
"Astha",
style: Theme.of(context).textTheme.headline2,
),
content: const Divider(
thickness: 1.0,
color: Colors.black,
),
actions: [
// Past two links as list tiles
ListTile(
leading: Icon(
Icons.person_outline_rounded,
color: Theme.of(context).colorScheme.secondary,
),
title: const Text('User Profile'),
onTap: () {
print("User Profile Button Pressed");
}),
ListTile(
leading: Icon(
Icons.logout,
color: Theme.of(context).colorScheme.secondary,
),
title: const Text('Logout'),
onTap: () {
Provider.of<AuthStateProvider>(context, listen: false).logOut();
GoRouter.of(context).goNamed(APP_PAGE.auth.routeName);
}),
],
);
}
}
Теперь пользователь может выйти из системы.
Функции Firebase: Триггеры облачного хранилища
Firebase предоставляет фоновые триггеры, которые вызываются автоматически при наступлении события, к которому они привязаны. Существует четыре триггера: onCreate, onUpdate, onDelete и onWrite. Мы будем использовать триггер onCreate, когда регистрируется новый пользователь, чтобы добавить поле временной метки createdAt, которое фиксирует время регистрации. Мы напишем нашу функцию в файле index.js в папке functions.
index.js
// Import modules
// #1
const functions = require("firebase-functions"),
admin = require('firebase-admin');
// always initialize admin
// #2
admin.initializeApp();
// create a const to represent firestore
// #3
const db = admin.firestore();
// Create a new background trigger function
// #4
exports.addTimeStampToUser = functions.runWith({
timeoutSeconds: 240, // Give timeout // #5
memory: "512MB" // memory allotment // #5
}).firestore.document('users/{userId}').onCreate(async (_, context) => {
// Get current timestamp from server
// #6
let curTimeStamp = admin.firestore.Timestamp.now();
// Print current timestamp on server
// # 7
functions.logger.log(`curTimeStamp ${curTimeStamp.seconds}`);
try {
// add the new value to new users document
// #8
await db.collection('users').doc(context.params.userId).set({ 'registeredAt': curTimeStamp, 'favTempleList': [], 'favShopsList': [], 'favEvents': [] }, { merge: true });
// if its done print in logger
// #7
functions.logger.log(`The current timestamp added to users collection: ${curTimeStamp}`);
// always return something to end the function execution
return { 'status': 200 };
} catch (e) {
// Print error incase of errors
// #7
functions.logger.log(`Something went wrong could not add timestamp to users collectoin ${curTimeStamp}`);
// return status 400 for error
return { 'status': 400 };
}
});
Надеюсь, читатели знакомы с JavaScript и Node.js. Давайте рассмотрим важные детали в файле index.js.
- Мы импортируем функции firebase и firebase-admin.
- Всегда помните об инициализации admin.
- Создаем константу для представления Firebase Firestore.
- Создаем нашу первую функцию, addTimeStampToUser, которая будет срабатывать всякий раз, когда в коллекции “users” будет создан новый документ.
- Мы можем задать ограничения для функции с помощью метода runWtih(). Иногда важно просто завершить функцию, чтобы освободить память и избавить себя от страшных счетов. Но с этим нужно экспериментировать.
- Мы можем получить временную метку с сервера. Но это всего лишь временная метка, а не время. Пожалуйста, прочитайте эту замечательную статью, чтобы узнать об этом больше.
- Мы печатаем журналы, чтобы увидеть прогресс.
- Мы можем сохранить новые поля в документе с помощью db.collection(‘collection_name’).doc(documentId).set(value). Здесь нужно отметить несколько моментов. Поскольку пользователь создается, нет context.auth.uid, но мы можем получить новый id из context.params.id. Узнайте больше о EventContext. Еще один момент: когда вы обновляете/добавляете новое значение, вы должны указать параметры слияния, чтобы убедиться, что новое значение не перезапишет предыдущие (email&username в нашем случае).
Вы видели, что поля ‘favTempleList’, ‘favShopsList’ и ‘favEvents’ были добавлены в документ пользователя. Не беспокойтесь об этом сейчас. Эти массивы будут заполнены позже. На вашем эмуляторе вы увидите эти поля и журналы, когда мы зарегистрируем нового пользователя.
Если вы хотите развернуть функции в облаке, сначала вам придется обновить планы до платного. После обновления используйте следующую команду для развертывания функций.
firebase deploy --only functions
Посмотрите этот плейлист от Google, чтобы узнать больше о Firebase Functions.
Резюме
В этом блоге мы выполнили довольно много задач. Давайте проследим наши шаги:
- Мы настроили AppRouter для перенаправления на экран аутентификации при запуске приложения, если пользователь не вошел в систему.
- Мы создали функции Registration, Login и LogOut с помощью пакета FlutterFire.
- Мы также написали простой фоновый триггер для Firebase Functions, который будет автоматически сохранять время, когда пользователь регистрируется в нашем приложении.
Показать поддержку
Пожалуйста, дайте нам обратную связь в разделе комментариев. Ставьте лайк и делитесь статьей с друзьями, которым она может быть полезна. Спасибо за ваше время. В следующем выпуске мы добавим такие функции, как закусочная, диалоги изменения и индикаторы прогресса, чтобы улучшить работу пользователей на экране аутентификации. Итак, **подпишитесь**, чтобы получать уведомления.
Это Нибеш из Khadka’s Coding Lounge, фрилансерского агентства, которое занимается созданием веб-сайтов и мобильных приложений.