Введение
Здравствуйте и добро пожаловать на 9третий часть серии уроков по разработке приложений во Flutter. До этого мы уже сделали заставку, написали тему, сделали пользовательские виджеты, такие как панель приложений, нижняя навигационная панель и ящик. Также мы уже сделали экран аутентификации, настроили соединение с облаком firebase и эмуляторами и аутентифицировали пользователей в проектах firebase.
Как часть потока пользовательского экрана, мы сейчас находимся на этапе, когда нам нужно получить доступ к местоположению пользователя. Поэтому мы будем запрашивать местоположение пользователя, как только он пройдет аутентификацию и попадет на главную страницу. Мы также будем использовать Firebase Cloud Functions для сохранения местоположения пользователя в документе ‘users/userId’ в Firebase Firestore. Найти исходный код для начала этого раздела можно здесь.
Пакеты
В предыдущих работах мы уже устанавливали и настраивали пакеты Firebase. Сейчас нам понадобятся еще три пакета: Location, Google Maps Flutter и Permission Handler. Следуйте инструкциям на домашней странице пакетов или просто используйте версию, которую я использую ниже.
Самого пакета location достаточно, чтобы получить и разрешение, и местоположение. Однако permission_handler может получить разрешение для других задач, таких как камера, локальное хранилище и так далее. Следовательно, мы будем использовать оба пакета: один для получения разрешения, а другой для определения местоположения. Пока что мы будем использовать только пакет google maps для использования типов данных Latitude и Longitude.
Установка
В командной строке Терминал:
# Install location
flutter pub add location
# Install Permission Handler
flutter pub add permission_handler
# Install Google Maps Flutter
flutter pub add google_maps_flutter
Настройка пакетов
Пакет Location
Для пакета Location, чтобы иметь возможность запрашивать разрешение пользователя, нам необходимо добавить некоторые настройки.
Android
Для android в «android/app/src/main/AndroidManifest.xml» перед тегом приложения.
<!--
Internet permissions do not affect the `permission_handler` plugin but are required if your app needs access to
the internet.
-->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Permissions options for the `location` group -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<!-- Before application tag-->
<application android:label="astha" android:name="${applicationName}" android:icon="@mipmap/launcher_icon">
IOS
Для ios в «ios/Runner/Info.plist» в конце тега dict добавьте следующие настройки.
<!-- Permissions list starts here -->
<!-- Permission while running on backgroud -->
<key>UIBackgroundModes</key>
<string>location</string>
<!-- Permission options for the `location` group -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>Need location when in use</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Always and when in use!</string>
<key>NSLocationUsageDescription</key>
<string>Older devices need location.</string>
<key>NSLocationAlwaysUsageDescription</key>
<string>Can I have location always?</string>
<!-- Permission options for the `appTrackingTransparency` -->
<key>NSUserTrackingUsageDescription</key>
<string>appTrackingTransparency</string>
<!-- Permissions lists ends here -->
Обработчик разрешений
Android
Для android в «android/gradle.properties» добавьте эти настройки, если их там еще нет.
android.useAndroidX=true
android.enableJetifier=true
В файле «android/app/build.gradle» измените версию скомпилированного SDK на 31, если вы этого еще не сделали.
android {
compileSdkVersion 31
...
}
Что касается API разрешений, мы уже добавили их в файл AndroidManifest.XML.
IOS
Мы уже добавили разрешения в info.plist. К сожалению, я использую VS Code и не смог найти файл POD в каталоге ios.
Google Maps Flutter
Чтобы использовать карты Google, вам понадобится API-ключ для них. Получите его от Google Maps Platform. Следуйте инструкциям из readme пакета о том, как создать API-ключ. Создайте по два учетных данных для android и ios. После этого нам нужно будет добавить их в приложения для android и ios.
Android
Снова перейдите к файлу AndroidManifest.xml.
<manifest ...
<application ...
<meta-data android:name="com.google.android.geo.API_KEY"
android:value="YOUR KEY HERE"/>
<activity ...
В файле » android/app/build.gradle» измените минимальную версию SDK на 21, если вы этого еще не сделали.
...
defaultConfig {
...
minSdkVersion 21
...
IOS
В файле ios/Runner/AppDelegate.swift добавьте api ключ для ios.
// import gmap
import GoogleMaps
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
...
-> Bool {
// Add api key don't remove anything else
GMSServices.provideAPIKey("API KEY Here")
...
}
НЕ ДЕЛИТЕСЬ СВОИМ API КЛЮЧОМ, ДОБАВЬТЕ МАНИФЕСТ ANDROID И ФАЙЛ APPDELEGATE В GITIGNORE ПЕРЕД ПУБЛИКАЦИЕЙ.
Напоминание: Если что-то не работает, проверьте страницы read me in packages.
Местоположение пользователя в доступе
Давайте рассмотрим ряд событий, которые произойдут в тот крошечный момент, когда пользователь перейдет с экрана аутентификации на домашний экран.
- У пользователя будет запрошено разрешение на предоставление доступа к местоположению.
- Если разрешение положительное, местоположение пользователя будет получено и передано облачной функции HTTPS callable.
- Затем вызываемая функция получит идентификатор текущего пользователя. С этим идентификатором callable прочитает нужный документ из коллекции «users».
- Она проверит, пустое ли поле местоположения или нет.
- Если оно пустое, то будет записано новое слияние документа для добавления местоположения.
- Если поле не пустое, то функция просто ничего не напишет и вернется.
Поскольку по мере роста приложения количество необходимых разрешений может увеличиваться, а разрешение также является глобальным фактором, давайте создадим класс провайдера, который будет обрабатывать разрешения в папках «globals/providers».
На вашем терминале
# Make folder
mkdir lib/globals/providers/permissions
// make file
touch lib/globals/providers/permissions/app_permission_provider.dart
Статус разрешения приложения имеет четыре типа: разрешенное, запрещенное, ограниченное или постоянно запрещенное. Давайте сначала создадим перечисление для переключения этих значений в нашем приложении.
app_permission_provider.dart
enum AppPermissions {
granted,
denied,
restricted,
permanentlyDenied,
}
Создадим класс провайдера прямо под перечислением. Как упоминалось ранее, мы будем использовать permission_handler для получения разрешения и пакет location для получения местоположения.
import 'package:cloud_functions/cloud_functions.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/foundation.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:location/location.dart'
as location_package; // to avoid confusion with google_maps_flutter package
class AppPermissionProvider with ChangeNotifier {
// Start with default permission status i.e denied
// #1
PermissionStatus _locationStatus = PermissionStatus.denied;
// Getter
// #2
get locationStatus => _locationStatus;
// # 3
Future<PermissionStatus> getLocationStatus() async {
// Request for permission
// #4
final status = await Permission.location.request();
// change the location status
// #5
_locationStatus = status;
print(_locationStatus);
// notify listeners
notifyListeners();
return status;
}
}
- Мы начнем с разрешения по умолчанию, которое является запрещенным.
- Геттер статуса разрешения.
- Метод, возвращающий будущее типа Permission Status. Он понадобится нам позже.
- Метод из Permission Handler(request), который запрашивает разрешение пользователя.
- Присвоение нового статуса и последующее уведомление слушателей.
Теперь перейдем к следующему шагу задания, который заключается в получении местоположения и сохранении его в Firestore. Мы добавим несколько новых переменных и экземпляров, которые помогут нам достичь этого. Добавьте следующий код перед методом getLocationStatus.
// Instantiate FIrebase functions
// #1
FirebaseFunctions functions = FirebaseFunctions.instance;
// Create a LatLng type that'll be user location
// # 2
LatLng? _locationCenter;
// Initiate location from location package
// # 3
final location_package.Location _location = location_package.Location();
// # 4
location_package.LocationData? _locationData;
// Getter
// # 5
get location => _location;
get locationStatus => _locationStatus;
get locationCenter => _locationCenter as LatLng;
Давайте объясним коды, хорошо?
- Экземпляр Firebase functions необходим, потому что после этого мы создадим вызываемую функцию https для обработки отправки местоположения.
- Местоположение пользователя, которое будет возвращено вызываемой функцией HTTPS.
- Инстанцируйте пакет location.
- Местоположение, которое будет передано пакетом Location.
- Геттеры для получения частных значений из этого класса.
Firebase Function: HTTPS Callable
Наш метод getLocation для AppPermissionProvider, который мы создадим позже, будет вызывать HTTPS callable внутри него. Итак, давайте перейдем в index.js, чтобы создать метод onCall из функции firebase.
index.js
// Create a function named addUserLocation
exports.addUserLocation = functions.runWith({
timeoutSeconds: 60, // #1
memory: "256MB" //#1
}).https.onCall(async (data, context) => {
try {
// Fetch correct user document with user id.
// #2
let snapshot = await db.collection('users').doc((context.auth.uid)).get();
// functions.logger.log(snapshot['_fieldsProto']['userLocation']["valueType"] === "nullValue");
// Get Location Value Type
// #3
let locationValueType = snapshot['_fieldsProto']['userLocation']["valueType"];
// Check if field value for location is null
// # 4
if (locationValueType == 'nullValue') {
// # 5
await db.collection('users').doc((context.auth.uid)).set({ 'userLocation': data.userLocation }, { merge: true });
functions.logger.log(`User location added ${data.userLocation}`);
}
else {
// # 6
functions.logger.log(`User location not changed`);
}
}
catch (e) {
// # 7
functions.logger.log(e);
throw new functions.https.HttpsError('internal', e);
}
// #7
return data.userLocation;
});
В вызываемой функции addUserLocation выше мы:
- Обеспечиваем выделение памяти для функций с методом runWith().
- Получить документ пользователя на основе идентификатора пользователя, предоставленного EventContext.
- Получить тип значения поля Location. Если вы помните, во время процесса регистрации в файле auth_state_provider.dart мы сохранили заполненное поле userLocation как null. Это длинное snapshot[‘_fieldsProto’][‘userLocation’][«valueType»] я получил в результате экспериментов и печати значений. Вот почему лучше всего использовать эмуляторы.
- Если locationValueType равно null, это означает, что местоположение пользователя никогда не сохранялось ранее. Следовательно, переходим к созданию нового документа.
- Обновим документ пользователя с помощью userLocation из свойства data метода onCall. Это то же самое местоположение, которое будет передано из класса провайдера в эту функцию. Да, то же самое, которое было получено из пакета location.
- Если locationValueType не равно null, то мы не будем записывать новый документ.
- Возвращаем местоположение пользователя. Очень важно завершать вызываемые функции возвратом, в противном случае функция может работать дольше, что приведет к потреблению памяти, что может вызвать дополнительные счета от Firebase.
Использование HTTPS Callable с пакетом Location во Flutter
Когда наша вызываемая функция готова, давайте создадим метод Future, который будет использоваться приложением. В файле app_permission_provider.dart после метода getLocationStatus создайте метод getLocation.
Future<void> getLocation() async {
// Call Location status function here
// #1
final status = await getLocationStatus();
// if permission is granted or limited call function
// #2
if (status == PermissionStatus.granted ||
status == PermissionStatus.limited) {
try {
// assign location data that's returned by Location package
// #3
_locationData = await _location.getLocation();
// Check for null values
// # 4
final lat = _locationData != null
? _locationData!.latitude as double
: "Not available";
final lon = _locationData != null
? _locationData!.longitude as double
: "Not available";
// Instantiate a callable function
// # 5
HttpsCallable addUserLocation =
functions.httpsCallable('addUserLocation');
// finally call the callable function with user location
// #6
final response = await addUserLocation.call(
<String, dynamic>{
'userLocation': {
'lat': lat,
'lon': lon,
}
},
);
// get the response from callable function
// # 7
_locationCenter = LatLng(response.data['lat'], response.data['lon']);
} catch (e) {
// incase of error location witll be null
// #8
_locationCenter = null;
rethrow;
}
}
// Notify listeners
notifyListeners();
}
}
Что мы сделали здесь:
- Запрашиваем разрешение пользователя на доступ к местоположению.
- Если разрешение разрешено/ограничено, т.е. всегда разрешено/запрещено при использовании приложения, то мы попытаемся получить доступ к местоположению.
- Для доступа к данным о местоположении пользовательские пакеты getLocation. Он возвращает тип объекта LatLng.
- Проверьте, являются ли возвращаемые данные null или нет, если да, то обработайте их соответствующим образом.
- (5&6)Внутри блока try мы инстанцируем вызываемую функцию HTTPS, как описано в пакете FlutterFire. Наша вызываемая функция принимает параметр «userLocation» в виде словаря с lat и lon в качестве ключей. После вызова этой функции в фоновом режиме она возвращает объект LatLng, к которому можно получить доступ из объекта данных ответа.
- В случае ошибки местоположение пользователя определяется как нулевое.
Теперь, когда местоположение пользователя обновляется, соответствующие виджеты, слушающие метод, будут уведомлены. Но чтобы виджеты могли обращаться к провайдеру, нам нужно будет добавить провайдер в список MultiProvider в нашем файле app.dart.
...
providers: [
...
ChangeNotifierProvider(create: (context) => AppPermissionProvider()),
...
],
FutureBuilder на помощь
Наша операция для получения местоположения пользователя является асинхронной и возвращает Future. Future может занять время для возврата результата, поэтому обычный виджет не будет работать. Для этой задачи предназначен класс FutureBuilder из flutter.
Мы вызовем метод getLocation из виджета Home в файле home.dart как свойство future класса FutureBuilder. В ожидании сохранения местоположения мы можем просто отобразить индикатор выполнения.
// Import the provider Package
import 'package:temple/globals/providers/permissions/app_permission_provider.dart';
// Inside Scaffold body
...
body: SafeArea(
child: FutureBuilder(
// Call getLocation function as future
// its very very important to set listen to false
// #1
future: Provider.of<AppPermissionProvider>(context, listen: false)
.getLocation(),
// don't need context in builder for now
builder: ((_, snapshot) {
// if snapshot connectinState is none or waiting
// # 2
if (snapshot.connectionState == ConnectionState.waiting ||
snapshot.connectionState == ConnectionState.none) {
return const Center(child: CircularProgressIndicator());
} else {
// if snapshot connectinState is active
// # 3
if (snapshot.connectionState == ConnectionState.active) {
return const Center(
child: Text("Loading..."),
);
}
// if snapshot connectinState is done
// #4
return const Center(
child: Directionality(
textDirection: TextDirection.ltr,
child: Text("This Is home")),
);
}
})),
),
...
В виджете home после импорта класса AppPermissionProvider мы вернули FutureBuilder как дочернее свойство виджета Safe Area. В нем мы:
-
User getLocation класса AppPermissionProvider как future. Очень важно не забыть установить значение listen на false. Иначе билд будет постоянно перезагружаться и функции будут выполняться снова и снова.
-
Мы возвращаем CircularProgressIndicator, ожидая завершения результата в фоновом режиме. Пока кажется, что в этом нет смысла, потому что мы не используем местоположение пользователя в нашем приложении. Итак, зачем нужен индикатор прогресса? Это для будущего, где мы снова используем этот момент для получения других данных из firebase, которые также будут асинхронными.
-
Когда будущее активно, мы отображаем текст с надписью loading.
-
После завершения работы будущего мы загружаем простую домашнюю страницу.
Окончательный код
app_permission_provider.dart
import 'package:cloud_functions/cloud_functions.dart';
import 'package:flutter/foundation.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:location/location.dart'
as location_package; // to avoid confusion with google_maps_flutter package
enum AppPermissions {
granted,
denied,
restricted,
permanentlyDenied,
}
class AppPermissionProvider with ChangeNotifier {
// Start with default permission status i.e denied
PermissionStatus _locationStatus = PermissionStatus.denied;
// Instantiate FIrebase functions
FirebaseFunctions functions = FirebaseFunctions.instance;
// Create a LatLng type that'll be user location
LatLng? _locationCenter;
// Initiate location from location package
final location_package.Location _location = location_package.Location();
location_package.LocationData? _locationData;
// Getter
get location => _location;
get locationStatus => _locationStatus;
get locationCenter => _locationCenter as LatLng;
Future<PermissionStatus> getLocationStatus() async {
// Request for permission
final status = await Permission.location.request();
// change the location status
_locationStatus = status;
// notiy listeners
notifyListeners();
print(_locationStatus);
return status;
}
Future<void> getLocation() async {
// Call Location status function here
final status = await getLocationStatus();
print("I am insdie get location");
// if permission is granted or limited call function
if (status == PermissionStatus.granted ||
status == PermissionStatus.limited) {
try {
// assign location data that's returned by Location package
_locationData = await _location.getLocation();
// Check for null values
final lat = _locationData != null
? _locationData!.latitude as double
: "Not available";
final lon = _locationData != null
? _locationData!.longitude as double
: "Not available";
// Instantiate a callable function
HttpsCallable addUserLocation =
functions.httpsCallable('addUserLocation');
// finally call the callable function with user location
final response = await addUserLocation.call(
<String, dynamic>{
'userLocation': {
'lat': lat,
'lon': lon,
}
},
);
// get the response from callable function
_locationCenter = LatLng(response.data['lat'], response.data['lon']);
} catch (e) {
// incase of error location witll be null
_locationCenter = null;
rethrow;
}
}
// Notify listeners
notifyListeners();
}
}
index.js
// Import modiules
const functions = require("firebase-functions"),
admin = require('firebase-admin');
// always initialize admin
admin.initializeApp();
// create a const to represent firestore
const db = admin.firestore();
// Create a new background trigger function
exports.addTimeStampToUser = functions.runWith({
timeoutSeconds: 240, // Give timeout
memory: "512MB" // memory allotment
}).firestore.document('users/{userId}').onCreate(async (_, context) => {
// Get current timestamp from server
let curTimeStamp = admin.firestore.Timestamp.now();
// Print current timestamp on server
functions.logger.log(`curTimeStamp ${curTimeStamp.seconds}`);
try {
// add the new value to new users document i
await db.collection('users').doc(context.params.userId).set({ 'registeredAt': curTimeStamp, 'favTempleList': [], 'favShopsList': [], 'favEvents': [] }, { merge: true });
// if its done print in logger
functions.logger.log(`The current timestamp added to users collection: ${curTimeStamp.seconds}`);
// always return something to end the function execution
return { 'status': 200 };
} catch (e) {
// Print error incase of errors
functions.logger.log(`Something went wrong could not add timestamp to users collectoin ${curTimeStamp.seconds}`);
// return status 400 for error
return { 'status': 400 };
}
});
// Create a function named addUserLocation
exports.addUserLocation = functions.runWith({
timeoutSeconds: 60,
memory: "256MB"
}).https.onCall(async (data, context) => {
try {
// Fetch correct user document with user id.
let snapshot = await db.collection('users').doc((context.auth.uid)).get();
// Check if field value for location is null
// functions.logger.log(snapshot['_fieldsProto']['userLocation']["valueType"] === "nullValue");
let locationValueType = snapshot['_fieldsProto']['userLocation']["valueType"];
if (locationValueType == 'nullValue') {
await db.collection('users').doc((context.auth.uid)).set({ 'userLocation': data.userLocation }, { merge: true });
functions.logger.log(`User location added ${data.userLocation}`);
return data.userLocation;
}
else {
functions.logger.log(`User location not changed`);
}
}
catch (e) {
functions.logger.log(e);
throw new functions.https.HttpsError('internal', e);
}
return data.userLocation;
});
home.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
// Custom
import 'package:temple/globals/providers/permissions/app_permission_provider.dart';
import 'package:temple/globals/widgets/app_bar/app_bar.dart';
import 'package:temple/globals/settings/router/utils/router_utils.dart';
import 'package:temple/globals/widgets/bottom_nav_bar/bottom_nav_bar.dart';
import 'package:temple/globals/widgets/user_drawer/user_drawer.dart';
class Home extends StatefulWidget {
const Home({Key? key}) : super(key: key);
@override
State<Home> createState() => _HomeState();
}
class _HomeState extends State<Home> {
// create a global key for scafoldstate
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey();
@override
Widget build(BuildContext context) {
return Scaffold(
// Provide key to scaffold
key: _scaffoldKey,
// Changed to custom appbar
appBar: CustomAppBar(
title: APP_PAGE.home.routePageTitle,
// pass the scaffold key to custom app bar
// #3
scaffoldKey: _scaffoldKey,
),
// Pass our drawer to drawer property
// if you want to slide right to left use
endDrawer: const UserDrawer(),
bottomNavigationBar: const CustomBottomNavBar(
navItemIndex: 0,
),
primary: true,
body: SafeArea(
child: FutureBuilder(
// Call getLocation function as future
// its very very important to set listen to false
future: Provider.of<AppPermissionProvider>(context, listen: false)
.getLocation(),
// don't need context in builder for now
builder: ((_, snapshot) {
// if snapshot connectinState is none or waiting
if (snapshot.connectionState == ConnectionState.waiting ||
snapshot.connectionState == ConnectionState.none) {
return const Center(child: CircularProgressIndicator());
} else {
// if snapshot connectinState is active
if (snapshot.connectionState == ConnectionState.active) {
return const Center(
child: Text("Loading..."),
);
}
// if snapshot connectinState is done
return const Center(
child: Directionality(
textDirection: TextDirection.ltr,
child: Text("This Is home")),
);
}
})),
),
);
}
}
Резюме
Этот блог был посвящен обработке разрешений и доступу к местоположению. Задачи, выполненные в этом блоге, следующие:
- Установил три пакета: Location, Permission Handler и Google Maps Flutter.
- Потратили немного времени на обновление настроек, необходимых для использования этих пакетов.
- Создал класс провайдера, который будет запрашивать у пользователя разрешение на доступ к местоположению.
- В этом же классе есть метод, который будет получать доступ к местоположению и вызывать вызываемую функцию HTTPS.
- Создана HTTPS-функция, которая будет обновлять местоположение пользователя в Firebase Firestore.
- Реализовали класс провайдера с помощью FutureBuilder в нашем приложении.
Показать поддержку
Хорошо, на этом мы закончили. Эта серия еще не закончена, в следующем выпуске мы более подробно рассмотрим Google Places API, Firebase Firestore и Firebase Cloud Functions.
Итак, пожалуйста, ставьте лайк, комментируйте и делитесь статьей со своими друзьями. Спасибо за ваше время, а тем, кто подписался на рассылку новостей блога, мы очень признательны. Продолжайте поддерживать нас. Это Нибеш из Khadka’s Coding Lounge, фрилансерского агентства, которое занимается созданием веб-сайтов и мобильных приложений.