В предыдущем посте я описал, как создать приложение Nativescript Vue v2 с экраном входа и Firebase для аутентификации. В этом посте будет показано, как расширить его до более реалистичного приложения Nativescript Vue, использующего различные сервисы Firebase. Мы начнем с добавления общих страниц приложений и сосредоточимся на функциональной странице профиля и маршрутизации входа/выхода. Приложение будет использовать VueX для локального состояния и маршрутизации приложения, Firebase Firestore в качестве удаленной базы данных и Firebase Storage для хранения изображений профиля.

Начните с базового приложения для входа в предыдущем посте. Клонируйте его, используя:

git clone https://github.com/drangelod/nsvfblogin nsvfbprofile 
cd nsvfbprofile 
npm i 
tns run ios --bundle

Аутентификация и маршрутизация

Приложения, использующие Firebase для аутентификации и данных, должны будут сначала инициализировать Firebase SDK, а затем проверить, вошел ли пользователь в текущее приложение/устройство. До тех пор приложение может отображать экран загрузки, пока выполняются инициализация Firebase и проверки аутентификации, а затем решать, следует ли перенаправлять пользователя на домашнюю страницу в качестве аутентифицированного пользователя или на страницу входа для аутентификации. Если пользователь уже вошел в систему с помощью Firebase, любые дальнейшие необходимые вызовы должны быть сделаны до перенаправления на домашнюю страницу приложения. Всякий раз, когда пользователь выходит из Firebase, приложение должно перенаправляться на страницу входа. Я модифицирую приложение nsvfblogin в полезное скелетное приложение с персонализированной домашней страницей и страницей рабочего профиля, используя различные сервисы Firebase.

Библиотека управления состоянием VueX будет использоваться для управления состоянием на разных страницах Vue. Затем приложение может инициировать действия при изменении состояния, например при входе в приложение или выходе из него. Давайте установим библиотеку VueX:

npm i vuex --save

Теперь давайте подготовим страницу загрузки. Текущая страница входа будет изменена, чтобы отображать только логотип приложения, некоторый текст и счетчик, пока не завершится инициализация Firebase. Я добавлю новую локальную переменную состояния isInitialized с начальным значением false, а затем добавлю директивы v-show на основе этого флага, чтобы скрыть все элементы страницы входа, кроме логотипа, названия приложения и нового текста загрузчика Label и ActivityIndicator счетчика.

Отредактируйте LoginPage.vue, чтобы XML-код шаблона теперь содержал:

<Page actionBarHidden="true" backgroundSpanUnderStatusBar="true">
    <ScrollView>
  <FlexboxLayout class="page">
   <StackLayout class="form">
    <Image class="logo" src="~/assets/images/NativeScript-Vue.png" />
    <Label class="header" text="APP NAME" />
        <StackLayout v-show="!isInitialized">
          <Label text="Loading" class="loading-label"/>
          <ActivityIndicator  busy="true" class="activity-indicator"/>
        </StackLayout>
    <StackLayout v-show="isInitialized" class="input-field" marginBottom="25">
     <TextField class="input" hint="Email" keyboardType="email" autocorrect="false" autocapitalizationType="none" v-model="user.email"
      returnKeyType="next" @returnPress="focusPassword" fontSize="18" />
     <StackLayout class="hr-light" />
    </StackLayout>
<StackLayout v-show="isInitialized" class="input-field" marginBottom="25">
     <TextField ref="password" class="input" hint="Password" secure="true" v-model="user.password" :returnKeyType="isLoggingIn ? 'done' : 'next'"
      @returnPress="focusConfirmPassword" fontSize="18" />
     <StackLayout class="hr-light" />
    </StackLayout>
<StackLayout v-show="(isInitialized && !isLoggingIn)"  class="input-field">
     <TextField ref="confirmPassword" class="input" hint="Confirm password" secure="true" v-model="user.confirmPassword" returnKeyType="done"
      fontSize="18" />
     <StackLayout class="hr-light" />
    </StackLayout>
<Button v-show="(isLoggingIn && isInitialized)" :text="isLoggingIn ? 'Log In' : 'Sign Up'" @tap="submit" class="btn btn-primary m-t-20" />
        <Button v-show="(isLoggingIn && isInitialized)" :text="'\uf09a' +' Facebook login'" @tap="loginFacebook" class="fab btn btn-active" />
        <Button v-show="(isLoggingIn && isInitialized)" :text="'\uf1a0' +' Google login' " @tap="loginGoogle" class="fab btn btn-active" />
    <Label v-show="(isLoggingIn && isInitialized)" text="Forgot your password?" class="login-label" @tap="forgotPassword" />
   </StackLayout>
<Label v-show="isInitialized" class="login-label sign-up-label" @tap="toggleForm">
           <FormattedString>
             <Span :text="isLoggingIn ? 'Don’t have an account? ' : 'Back to Login'" />
             <Span :text="isLoggingIn ? 'Sign up' : ''" class="bold" />
           </FormattedString>
         </Label>
  </FlexboxLayout>
    </ScrollView>
</Page>

Затем отредактируйте export default { data(), чтобы добавить новый флаг:

export default {
  data() {
    return {
      isLoggingIn: true,
      isInitialized: false,
      user: {
        email: "[email protected]",
        password: "tester",
        confirmPassword: "tester"
      }
    };
  },

После этих изменений приложение должно выглядеть так:

Теперь давайте начнем добавлять общие компоненты для использования на разных страницах приложения. Сначала удалите метод userServicestub в LoginPage.vue. Пока вы редактируете эту страницу, удалите alert() после успешной регистрации из функции register(), чтобы избежать проблем с отображением в дальнейшем.

Функциональность аутентификации будет сохранена в /app/services/AuthService.js :

import firebase from "nativescript-plugin-firebase";
import BackendService from "./BackendService";
import { backendService } from "../main";
export default class AuthService extends BackendService {
  async register(user) {
    return await firebase.createUser({
      email: user.email,
      password: user.password
    });    
  }
async login(user) {
    await firebase
      .login({
        type: firebase.LoginType.PASSWORD,
        passwordOptions: {
          email: user.email,
          password: user.password
        }
      })
      .then(async firebaseUser => {
        backendService.token = firebaseUser.uid;
        return firebaseUser                
      })
  }
async loginFacebook(user) {
    await firebase
      .login({
        type: firebase.LoginType.FACEBOOK,
        facebookOptions: {
          scope: ["public_profile", "email"] 
        }
      })
      .then(result => {
        return Promise.resolve(JSON.stringify(result));
      })
      .catch(error => {
        console.log("Error logging in with Facebook")
        console.error(error);
        return Promise.reject(error);
      });
  }
async loginGoogle(user) {
     await firebase
      .login({
        type: firebase.LoginType.GOOGLE       
      })
      .then(result => {
        return Promise.resolve(JSON.stringify(result));
      })
      .catch(error => {
        console.log("Error logging in with Facebook")
        console.error(error);
        return Promise.reject(error);
      });
  }
async resetPassword(email) {
    return await firebase.resetPassword({
      email: email
    })
  }
async logout() {
    backendService.token = "";
    return await firebase.logout();
  }
}

Также добавьте /app/services/BackendService.js, из которого расширяется AuthService:

import { getString, setString } from 'tns-core-modules/application-settings'
const tokenKey = "token";
export default class BackendService {
  
  isLoggedIn() {
    return !!getString(tokenKey);
  }
get token() {
    return getString(tokenKey);
  }
set token(newToken) {
    setString(tokenKey, newToken);
  } 
}

Теперь измените /app/main.js, чтобы к классу AuthService можно было получить доступ в любом месте приложения.

import Vue from 'nativescript-vue'
import VueDevtools from 'nativescript-vue-devtools'
import firebase from "nativescript-plugin-firebase"  
import BackendService from './services/BackendService' 
import AuthService from './services/AuthService' 
import LoginPage from './components/LoginPage'
//shared among components
export const backendService = new BackendService()
export const authService = new AuthService()
Vue.prototype.$authService = authService
Vue.prototype.$backendService = backendService
if(TNS_ENV !== 'production') {
  Vue.use(VueDevtools)
}
Vue.config.silent = (TNS_ENV === 'production')
firebase
  .init({
    onAuthStateChanged: data => { 
    console.log((data.loggedIn ? "Logged in to firebase" : "Logged out from firebase") + " (firebase.init() onAuthStateChanged callback)");
    if (data.loggedIn) {
      backendService.token = data.user.uid
      console.log("uID: " + data.user.uid)
    }
    else {      
    }
  }
  })
  .then(
    function(instance) {
      console.log("firebase.init done");
    },
    function(error) {
      console.log("firebase.init error: " + error);
    }
  );
new Vue({
  render: h => h('frame', [h(LoginPage)])
}).$start()

Флаг isInitialized будет переключен после того, как firebase будет инициализирована и готова для входа в приложение. Если вы запустите приложение сейчас, вы увидите идентификатор пользователя Firebase в журнале консоли после входа в систему, который будет использоваться позже для индексации данных профиля в Firestore.

Добавление анимации сгладит переходы между состояниями страницы входа. Немного измените шаблон, чтобы заключить все в <FlexboxLayout class="page"> с новым тегом <StackLayout v-bind:class="getClass()" >, чтобы он выглядел так:

<Page actionBarHidden="true" backgroundSpanUnderStatusBar="true">
    <ScrollView>
   <FlexboxLayout class="page">
        <StackLayout v-bind:class="getClass()" >
          <StackLayout class="form">
            <Image class="logo" src="~/assets/images/NativeScript-Vue.png" />
              ........

Это было сделано для динамического присвоения класса всем элементам страницы в зависимости от результата функции getClass(). Добавьте его к объекту methods: {:

getClass() {
   return {
      "container-loading": this.isInitialized,
      "container-loaded": !this.isInitialized
   };
},

Добавьте эти два класса и их анимации в раздел стилей LoginPage CSS:

.container-loading {
  animation-name: loading;
  animation-fill-mode: forwards;
  animation-duration: 0.6;
  animation-iteration-count: 1;
}
@keyframes loading {
  0% {
    transform: translate(0, 200);
  }
  100% {
    transform: translate(0, 0);
  }
}
.container-loaded {
  animation-name: loaded;
  animation-fill-mode: forwards;
  animation-duration: 0.6;
  animation-iteration-count: 1;
}
@keyframes loaded {
  0% {
    transform: translate(0, 0);
  }
  100% {
    transform: translate(0, 200);
  }
}

Поскольку пока нет способа переключить флаг isInitialized, давайте добавим код отладки, чтобы увидеть, что происходит на данный момент. Добавьте const timerModule = require("tns-core-modules/timer") рядом с другими импортами, и теперь можно установить таймер для переключения флага и использования экрана входа в систему. Ловушка метода created(), предоставляемая Vue, может использоваться для установки таймера после создания страницы. Добавьте к объекту export default { следующее:

created() {
    setTimeout(() => {
      this.isInitialized = true;
    }, 1500);
  },

Запуск приложения должен отображать страницу загрузки в течение 1,5 секунд, а затем компоненты страницы перемещаются вверх, прежде чем отобразится остальная часть формы входа. Вызовы входа, регистрации и забытого пароля на странице входа необходимо обновить, чтобы использовать новый объект AuthService. Измените следующие функции внутри объекта export default { methods{ object:

login() {
      this.$authService
        .login(this.user)
        .then(() => {
          loader.hide();
          this.$navigateTo(DashboardPage);
        })
        .catch(err => {
          console.error(err);
          loader.hide();
          this.alert(err);
        });
    },
    loginFacebook() {
      //loader.show();//Don't use this for facebook logins, as the popup covers the UI on IOS
      if (isIOS) this.isInitialized = false;
      if(isAndroid) loader.show();
      this.$authService
        .loginFacebook(this.user)
        .then(() => {
          //if (isIOS) this.isInitialized = true; //leave this up to avoid weird animation           
          if(isAndroid) loader.hide();
          this.$navigateTo(DashboardPage);
        })
        .catch(err => {
          if (isIOS) this.isInitialized = true;
          else loader.hide();
          console.error(err);
          this.alert(err);
        });
    },
    loginGoogle() {
      if (isIOS) this.isInitialized = false;
      else loader.show();
this.$authService
        .loginGoogle(this.user)
        .then(result => {
          //if (isIOS) this.isInitialized = true;
          if(isAndroid) loader.hide();
          this.$navigateTo(DashboardPage);
        })
        .catch(error => {
          if (isIOS) this.isInitialized = true;
          else loader.hide();
          console.error(err);
          this.alert(error);
        });
    },
    register() {
      if (this.user.password != this.user.confirmPassword) {
        loader.hide();
        this.alert("Your passwords do not match.");
        return;
      }
      if (this.user.password.length < 6) {
        loader.hide();
        this.alert("Your password must at least 6 characters.");
        return;
      }
      var validator = require("email-validator");
      if (!validator.validate(this.user.email)) {
        loader.hide();
        this.alert("Please enter a valid email address.");
        return;
      }
      this.$authService
        .register(this.user)
        .then(() => {
          loader.hide();
          this.alert("Your account was successfully created.");
          this.isLoggingIn = true;
        })
        .catch(err => {
          console.error(err);
          loader.hide();
          this.alert(err);
        });
    },
    forgotPassword() {
      prompt({
        title: "Forgot Password",
        message:
          "Enter the email address you used to register for APP NAME to reset your password.",
        inputType: "email",
        defaultText: "",
        okButtonText: "Ok",
        cancelButtonText: "Cancel"
      }).then(data => {
        if (data.result) {
          loader.show();
          this.$authService
            .resetPassword(data.text.trim())
            .then(() => {
              loader.hide();
              this.alert(
                "Your password was successfully reset. Please check your email for instructions on choosing a new password."
              );
            })
            .catch(err => {
              loader.hide();
              this.alert(err);
            });
        }
      });
    },

Одним из изменений является использование служебных функций isIOS и isAndroid для использования компонента LoadingIndicator для Android и использования флага isInitialized для iOS, поскольку этот компонент охватывает процесс входа в Facebook и Google на iOS. Чтобы использовать эти функции, добавьте import { isAndroid, isIOS } from "tns-core-modules/platform"; на любую страницу, требующую пользовательской логики платформы.

Добавление страницы профиля

Другим важным изменением является то, что приложение теперь перенаправляется на новую страницу DashboardPage.vue после успешного входа в систему вместо фрагмента, который использовался ранее. На этой странице будет использоваться компонент Nativescript TabView с 3 вкладками, каждая из которых имеет настраиваемую панель действий сверху. Панель действий будет иметь ссылку на новую страницу профиля, а также на новую страницу настроек, которая будет содержать кнопку выхода.

Добавьте import DashboardPage from "./DashboardPage.vue";, а затем создайте /app/components/DashboardPage.vue со следующим содержимым:

<template>
    <Page class="page">
        <TabView androidTabsPosition="bottom" selectedTabTextColor="blue" androidSelectedTabHighlightColor="blue" :selectedIndex="selectedIndex" class="fas tab-title">
            <TabViewItem :title="'\uf57d'">
                <tab-global></tab-global>
            </TabViewItem>
            <TabViewItem :title="'\uf14e'"  >
                <tab-local></tab-local>
            </TabViewItem>
            <TabViewItem :title="'\uf54f'">
                <tab-store></tab-store>
            </TabViewItem>
        </TabView>            
 </Page>    
</template>
<script>
import GlobalTab from "@/components/GlobalTab";
import LocalTab from "@/components/LocalTab";
import StoreTab from "@/components/StoreTab";
export default {
  name: "dashboard-page",
  components: {
    "tab-global": GlobalTab,
    "tab-local": LocalTab,
    "tab-store": StoreTab
  },
  computed: {
  },
  methods: {
    init() {
      console.log("DashboardPage method init():");
    },
  },
  watch: {
  }
};
</script>
<style scoped>
</style>

Эта страница не имеет реального содержания и будет управлять страницами, отображаемыми в виде вкладок в приложении. Создайте три новые страницы с именами GlobalTab.vue, LocalTab.vue и StoreTab.vue внутри /app/components. Каждый из них будет иметь одинаковое начальное содержание:

<template>
    <StackLayout>
        <ActionBar text="MyApp" :back="false"/>
        <StackLayout class="form">            
            <Image class="logo" src="~/assets/images/NativeScript-Vue.png" />
            <Label class="header">Welcome to Global!</Label>            
        </StackLayout>
    </StackLayout>
</template>
<script>
import ActionBar from "./ActionBar";
export default {
  name: "local-tab",
  components: {ActionBar},
};
</script>
<style scoped>
.logo {
  margin-bottom: 12;
  height: 90;
  font-weight: bold;
  horizontal-align: center;
}
.header {
  horizontal-align: center;
  font-size: 25;
  font-weight: 600;
  margin-bottom: 70;
  text-align: center;
  color: #66a59a;
}
</style>

На каждой странице используется один и тот же компонент ActionBar, который будет отображать настраиваемую панель действий вверху. Создайте /app/components/ActionBar.vue и добавьте:

<template>
<StackLayout>
    <GridLayout class="action-bar" rows="*" columns="50,2*,50,50,50">
        <Label v-if="back" col="0" row="0" class="fas" @tap="$navigateBack()" :text="'\uf060'"/>
        <Label col="1" row="0" class="header" :text="text"/>
        <Label col="2" row="0" class="fas" @tap="toggleSearch()" :text="'\uf002'"/>
        <Label col="3" row="0" class="fas" @tap="goSettings()" :text="'\uf013'"/>       
        <Label col="4" row="0" class="fas" @tap="goProfile()" :text="'\uf007'"/>
    </GridLayout>
     <SearchBar v-if="searchbar"  hint="Search for.." [text]="searchPhrase" (textChange)="onTextChanged($event)" (submit)="onSubmit($event)" 
                color="grey" textFieldBackgroundColor="lightgrey" textFieldHintColor="darkgrey"></SearchBar>
</StackLayout>
</template>
<script>
import SettingsPage from "@/components/SettingsPage";
import ProfilePage from "@/components/ProfilePage";
export default {
  name: "actionbar-component",
  props: {
    text: {},
    back: {
      type: Boolean,
      default: true
    }
  },
  data() {
    return {
      searchbar: false
    };
  },
  methods: {
    goSettings() {
      this.$navigateTo(SettingsPage);
    },
    goProfile() {
      this.$navigateTo(ProfilePage);
    },
    toggleSearch() {
      this.searchbar = !this.searchbar;
    }
  }
};
</script>
<style>
.action-bar {
  background-color: #90D2C5;
  color: #131426;
  font-weight: bold;
  font-size: 20;
  font-family: "Quicksand";
  height: 30;
}
</style>

Теперь, если вы войдете в приложение, вы должны увидеть выбранную глобальную вкладку и панель действий в верхней части каждой вкладки.

ActionBar состоит из 3 основных элементов. Первый — это значок увеличительного стекла, который переключает отображение компонента SearchBar, который вы можете использовать позже, чтобы добавить функцию поиска на любую вкладку (и интегрировать с VueX для отфильтрованных данных о состоянии). Значок шестеренки перенаправит вас на страницу настроек, а значок человека — на страницу профиля. Давайте добавим страницу настроек как /app/components/SettingsPage.vue:

<template>
   <Page ref="page" actionBarHidden="true" backgroundSpanUnderStatusBar="true">
        <StackLayout class="container">
            <GridLayout class="action-bar" rows="*" columns="50,2*,50">
                <Label col="0" row="0" class="fas" @tap="$navigateBack()" :text="'\uf060'"/>
                <Label col="1" row="0" class="header" text="Settings"/>               
            </GridLayout>            
            <Button  text="Logout" class="logout" @tap="logoutApp()" />
        </StackLayout>
    </Page>
</template>
<script>
import LoginPage from "./LoginPage.vue";
export default {
  name: "settings-page",
  components: {},
  computed: {},
  created() {},
  methods: {
    logoutApp() {
      this.$authService.logout().then(() => {
        this.$navigateTo(LoginPage, { clearHistory: true });
      });
    }
  }
};
</script>
<style scoped>
.logout {
  horizontal-align: stretch;
  text-align: center;
  color: white;
  background-color: #1b1c1d;
  height: 36;
}
</style>

На этой странице есть кнопка, которая позволяет пользователю выйти из Firebase, а затем направить приложение на LoginPage при очистке истории переходов. Вы должны быть в состоянии войти в систему и выйти из нее сейчас, а затем вернуться к экрану входа в систему.

Теперь, если пользователь уже вошел в систему с помощью Firebase при запуске приложения, оно должно перенаправить пользователя на страницу панели инструментов и не требовать от него повторного входа в систему. Это можно сделать с помощью обработчика событий onAuthStateChanged, добавленного к вызову firebase.init(). Сначала я попытался использовать $navigateTo оттуда, чтобы загрузить страницу панели мониторинга после завершения инициализации, но в отличие от некоторых других примеров Nativescriptt Vue apps, которые я смотрел, я не смог заставить его работать. Вместо этого я установлю отслеживание изменений в данных, управляемых VueX, для маршрутизации на соответствующую страницу при изменении состояния входа в систему.

Хранилище VueX начнется с переменной флага, чтобы отслеживать, когда приложение входит в Firebase. Затем для этой переменной можно установить watch, чтобы направлять вошедших в систему пользователей на страницу информационной панели со страницы входа. Если пользователь еще не вошел в Firebase, можно установить флаг isInitialized на странице входа в систему, чтобы показать остальную часть экрана входа в систему. Выходы будут обрабатываться аналогичным образом позже в этом посте.

Давайте начнем с простого хранилища, создав новый файл /app/store/index.js со следующим содержимым:

import Vue from 'nativescript-vue';
import Vuex from 'vuex';
import firebase from 'nativescript-plugin-firebase'
Vue.use(Vuex);
const state = {
  isLoggedIn:null
}
const getters = {
  isLoggedIn: state =>{
    return state.isLoggedIn
  },
}
const mutations = {
  setIsLoggedIn: (state, value) => {
    state.isLoggedIn = value;
  }
}
const actions = {
}
const storeConf = {
  state,
  getters,
  mutations,
  actions
}
export default new Vuex.Store(storeConf)

В настоящее время в этом хранилище есть только одна переменная с именем isLoggedIn с начальным значением null и определены функции получения и преобразования для доступа к этой переменной. Функциональность VueX mapState будет использоваться для отслеживания изменений этой переменной и запуска действий. Для этого требуется добавить новый импорт на LoginPage.vue:

import { mapState } from "vuex";

VueX синхронизирует состояние этой переменной со страницей, добавив это в объект export default {:

computed: { ...mapState(["isLoggedIn"]) },

Теперь Vue сможет видеть изменения этой переменной с помощью действия наблюдателя, добавленного к объекту export default {:

watch: {   
    isLoggedIn(val) {
      if (!val) {        
        this.isInitialized = true;        
      }else{
        this.$navigateTo(DashboardPage, { clearHistory: true });
      }
    }
  },

Всякий раз, когда происходит изменение значения переменной isInitialized, элементы управления формы входа будут включены, если значение изменилось на false, и перенаправлены на панель инструментов, если это правда. Измените все методы входа, чтобы прекратить использование this.$navigateTo(DashboardPage); и начать использовать this.$store.commit('setIsLoggedIn', true) после успешного входа. Теперь измените вызов firebase.init() в /app/main.js, чтобы он выглядел так:

onAuthStateChanged: data => { 
    console.log((data.loggedIn ? "Logged in to firebase" : "Logged out from firebase") + " (firebase.init() onAuthStateChanged callback)");
    if (data.loggedIn) {
      backendService.token = data.user.uid
      console.log("uID: " + data.user.uid)
      store.commit('setIsLoggedIn', true)
    }
    else {      
      store.commit('setIsLoggedIn', false)
    }
}

Теперь, если вы запустите приложение и войдете в систему, а затем завершите его и перезапустите приложение, вы все равно должны войти в Firebase, и приложение автоматически направит вас на страницу панели инструментов. Точно так же после успешной регистрации учетной записи в Firebase пользователь автоматически войдет в систему и перенаправится на страницу панели инструментов.

Выходы из системы можно обрабатывать, наблюдая за изменением состояния после того, как приложение прошло страницу входа. Удалите вызов navigateTo() внутри функции logoutApp в SettingsPage.vue , так что функция теперь будет просто:

logoutApp() {
    this.$authService.logout()
}

Чтобы приложение автоматически перенаправляло пользователя на страницу входа после выхода из системы, можно установить часы для переменной setIsLoggedIn на странице панели инструментов. Добавьте к объекту export default { следующее:

watch: {
    isLoggedIn(val) {
      if (!val) {
        this.$navigateTo(LoginPage, { clearHistory: true });
      }
    }
  }

Наконец, удалите таймер, добавленный ранее, чтобы включить элементы управления входом в export default {object из LoginPage.vue, и замените его на:

created() {
    if(this.$store.state.isLoggedIn!=null){
      this.isInitialized = true;
    }
}

Это было добавлено для повторного отображения элементов управления входом в систему после возврата на страницу входа после выхода из системы.

Интеграция с базой данных Firestore

Для большинства приложений вы также захотите загружать некоторую основную информацию о пользователе при входе в систему, поэтому теперь пришло время добавить некоторое управление состоянием и хранение/извлечение данных с помощью Firestore. В этом посте я настрою приложение для управления информацией профиля пользователя в Firestore и загружу его после входа в систему, но перед загрузкой страницы панели инструментов, чтобы можно было отобразить индивидуальное приветственное сообщение с использованием этой информации профиля.

Сначала давайте настроим Firestore, перейдя на Firebase Console и выбрав «База данных» в главном меню:

Создайте базу данных Firestore для приложения. Когда вас спросят о правилах безопасности, выберите «тестовый режим», чтобы разрешить доступ для чтения и записи, не беспокоясь об ограничениях на данный момент. Информация о профиле будет храниться в коллекции с именем /users/. Данные профиля каждого пользователя будут храниться в документе как /users/<UID>, а записи будут индексироваться по идентификатору пользователя Firebase. Вы должны увидеть этот идентификатор в журнале консоли после входа в приложение. Нажмите «Добавить коллекцию» в консоли Firebase, чтобы создать коллекцию /users/ в Firestore для приложения.

На первой странице модального окна установите идентификатор коллекции как «пользователи». На второй странице добавьте документ с идентификатором пользователя Firebase, который вы видели после входа в приложение. Добавьте поля для id, name, bio, profile_pic и заполните соответствующие значения для каждого из них. ID будет тем же идентификатором пользователя Firebase, а profile_pic должен быть URL-адресом файла изображения.

Поддержка Firestore должна быть включена в плагине Nativescript Firebase, поэтому установите "firestore": true, внутри /firebase.nativescript.json. Затем выполните следующие команды, чтобы перестроить приложение с поддержкой Firestore:

rm -rf platforms && rm -rf node_modules && npm i 
tns run ios --bundle

При регистрации учетной записи необходимо создать пустой документ Firebase в коллекции users. Измените функцию register() в /app/services/AuthService.js следующим образом:

async register(user) {
    const createdUser = await firebase.createUser({
      email: user.email,
      password: user.password
    })
    return await firebase.firestore.set("users", createdUser.uid, {});
}

Давайте добавим поддержку нового объекта профиля в хранилище VueX. Отредактируйте /app/store/index.js и измените его на:

import Vue from 'nativescript-vue';
import Vuex from 'vuex';
import firebase from 'nativescript-plugin-firebase'
import ProfileService from '../services/ProfileService'
Vue.use(Vuex);
const state = {
  isLoggedIn:null,
  profile:null,
}
const getters = {
  isLoggedIn: state =>{
    return state.isLoggedIn
  },
  profile: state =>{
    return state.profile
  },
}
const mutations = {
  setIsLoggedIn: (state, value) => {
    state.isLoggedIn = value;
  },
  setProfile: (state, profile) => {
    state.profile = profile;
  },
}
const actions = {
  fetchProfile() {
    ProfileService.getCurrentUser()
  },
}
const storeConf = {
  state,
  getters,
  mutations,
  actions
}
export default new Vuex.Store(storeConf)

Были добавлены те же геттеры и мутаторы, что и для флага isLoggedIn, а также действие для получения текущих данных профиля для вошедшего в систему пользователя.

Создайте для этого новый файл под /app/services/ProfileService.js с содержимым:

import firebase from "nativescript-plugin-firebase";
import store from "../store";
export default {
  getProfile() {
    firebase.getCurrentUser().then(function(currentuser) {
      firebase.firestore.getDocument("users", currentuser.uid).then(docdata => {
        var userdata = {};
        userdata.id = currentuser.uid;
        if (docdata.exists) {
          var fbdata = docdata.data();
          userdata.name = fbdata.name;
          userdata.bio = fbdata.bio;
          userdata.profile_pic = fbdata.profile_pic;
        } else {
          firebase.firestore.set("users", currentuser.uid, {});
        }
        store.commit("setProfile", userdata);
      });
    });
  }
};

Если в Firebase еще нет документа профиля (логины Facebook или Google), ProfileService создаст пустой документ только с идентификатором пользователя.

Процесс входа в систему будет изменен, чтобы запрашивать данные профиля из Firestore после аутентификации, но перед маршрутизацией на страницу панели инструментов. Отслеживание переменной isLoggedIn теперь вызовет запрос данных профиля из Firebase. Новый watch в магазине profile будет использоваться для маршрутизации на панель управления. Измените объекты computed и watch в LoginPage.vue с помощью этих новых сегментов кода:

computed: {
    ...mapState(["isLoggedIn","profile"])
},
watch: {   
    isLoggedIn(val) {
      if (!val) {
        this.isInitialized = true;        
      }else{
        this.$store.dispatch("fetchProfile");
      }
    },
    profile(val) {
      if (!val) {
      }else{
        this.$navigateTo(DashboardPage, { clearHistory: true });        
      }
    }
  },

Теперь давайте немного настроим страницу GlobalTab.vue, чтобы использовать загруженные данные профиля. Добавьте import { mapState, mapGetters } from "vuex"; к импорту и computed: {...mapGetters(["profile"])}, к объекту export default {. mapState позволяет нам читать данные profile, загруженные в хранилище из firebase. Добавьте <Label class="header">Hello {{profile.name}}!</Label> под логотипом в разделе XML. Если вы запустите приложение, вы должны увидеть имя тестового пользователя на странице:

Когда данные доступны, следующим шагом будет создание страницы профиля, которая позволяет пользователю обновлять информацию своего профиля (тем более, что для новых пользователей с текущим процессом регистрации приложения нет такой страницы). Этот дизайн страницы профиля взят из демо-приложения Nativescript Marketplace, которое вы можете загрузить, чтобы увидеть, как различные плагины и дизайны можно использовать в приложении Nativescript. Отредактируйте /app/component/ProfilePage.vue и измените его на:

<template>
   <Page ref="page" actionBarHidden="true" backgroundSpanUnderStatusBar="true">
        <StackLayout class="container">
            <GridLayout class="action-bar" rows="*" columns="50,2*,50">
                <Label col="0" row="0" class="fas" @tap="$navigateBack()" :text="'\uf060'"/>
                <Label col="1" row="0" class="header" text="Profile"/>               
            </GridLayout>        
            <GridLayout>
              <GridLayout class="form-content" rows="auto,*,auto,auto" >
                  <StackLayout class="top-border" row="0"/>
                  <ScrollView row="1" class="fields-section">
                      <StackLayout>
                          <!-- Username -->
                          <GridLayout  ios:columns="auto,*" android:rows="auto, auto" verticalAlignment="top">
                              <Label text="Username" class="field-title" />
                              <TextField v-bind:class="{ editable: editable }" id="username" :editable="editable" v-model="origprofile.name" class="field" ios:col="1" android:row="1" tap="onTextInputTapped" />
                          </GridLayout>
                          <StackLayout class="line"/>
                          <!-- Profile Pic -->
                          <GridLayout  ios:columns="auto,*" android:rows="auto, auto" verticalAlignment="top">
                              <Label text="Profile Pic" class="field-title" />
                              <TextField v-bind:class="{ editable: editable }" id="profile_pic" :editable="editable" v-model="origprofile.profile_pic" class="field" ios:col="1" android:row="1" tap="onTextInputTapped" />
                          </GridLayout>
                          <StackLayout class="line"/>
                          <!-- Bio -->
                          <StackLayout >
                              <Label text="Bio" class="field-title" />
                              <TextView v-bind:class="{ editable: editable }" id="bio" :editable="editable" v-model="origprofile.bio" android:hint="Add bio" ios:hint="Bio" class="field-multiline" tap="onTextInputTapped"/>
                          </StackLayout>
                          <StackLayout class="line"/>
                      </StackLayout>
                  </ScrollView>                  
                  <Button row="2" v-show="!editable" text="Update" class="update" @tap="toggleForm" col="0" colspan="2"/>
                  <GridLayout row="3" columns="*,*"  >
                    <Button v-show="editable" text="Save" class="save" @tap="saveProfile" col="0"/>
                   <Button v-show="editable" text="Cancel" class="cancel" @tap="saveCancel" col="1"/>            
                  </GridLayout>                  
             </GridLayout>
             <!-- Picture -->
             <Image id="image" :src="origprofile.profile_pic" class="profile-picture" v-bind:class="{'editable': editable }" @tap="onProfilePictureTapped"/>      
            </GridLayout>
        </StackLayout>
    </Page>
</template>
<script>
import { mapState, mapGetters } from "vuex";
import firebase from "nativescript-plugin-firebase";
var LoadingIndicator = require("nativescript-loading-indicator")
  .LoadingIndicator;
var loader = new LoadingIndicator();
export default {
  name: "profile-page",
  data() {
    return {
      editable: false,
      origprofile: {}
    };
  },
  components: {},
  computed: {
  },
  created() {
    this.origprofile = Object.assign({}, this.$store.state.profile);
  },
  methods: {
    toggleForm() {
      this.editable = !this.editable;
    },
    onProfilePictureTapped() {
      console.log("profilepic tapped");
    },
    saveCancel() {
      this.origprofile = Object.assign({}, this.$store.state.profile);
      this.editable = false;
    },
    saveProfile() {
      //in case we updated profile pic and returned to this page
      this.origprofile.profile_pic = this.$store.state.profile.profile_pic
      loader.show();
      const profDoc = firebase.firestore
        .collection("users")
        .doc(this.origprofile.id);
      profDoc
        .set(this.origprofile, { merge: true })
        .then(
          res => {
            this.$store.commit("setProfile", this.origprofile);
            this.editable = false;
            loader.hide();
            return res;
          },
          err => {
            console.error(err);
            loader.hide();
            alert("Unable to save profile, try again later!");
          }
        )
        .catch(function(error) {
          alert("Unable to save profile, try again later!");
          loader.hide();
          console.error("Error writing firestore document: ", error);
        });
    }
  }
};
</script>
<style scoped>
.profile-picture {
  width: 80;
  height: 80;
  border-radius: 40;
  margin-top: 16;
  vertical-align: top;
  horizontal-align: center;
}
@keyframes picture {
  from {
    opacity: 0;
    transform: scale(2, 2);
    animation-timing-function: ease-in;
  }
  to {
    opacity: 1;
    transform: scale(1, 1);
  }
}
.top-border {
  height: 2;
  background-color: #899bfe;
  margin-bottom: 40;
}
@keyframes play {
  from {
    opacity: 0.3;
    transform: scale(0.6, 0.6);
  }
  60% {
    opacity: 0.6;
    transform: scale(1.1, 1.1);
    animation-timing-function: ease-in;
  }
  to {
    opacity: 1;
    transform: scale(1, 1);
  }
}
.form-content {
  background-color: white;
  margin-top: 56;
  margin-left: 32;
  margin-right: 32;
  margin-bottom: 42;
  opacity: 0.6;
  transform: scale(0.8, 0.8);
  animation-name: play;
  animation-fill-mode: forwards;
  animation-duration: 0.6;
}
.fields-section {
  margin-left: 12;
  margin-right: 12;
}
.field-title,
.field-switch-title {
  horizontal-align: left;
  font-size: 14;
  color: #737373;
  padding: 10 0;
}
.field {
  horizontal-align: stretch;
  text-align: right;
  font-size: 14;
  color: #1e2d7e;
  padding: 10 0;
}
.field-multiline {
  min-height: 60;
  font-size: 14;
  color: #1e2d7e;
  margin: 10 0;
}
.field-switch {
  vertical-align: center;
  horizontal-align: right;
  margin: 10 0;
}
.editable {
  background-color: #eceaea;
}
.edit-picture {
  width: 80;
  height: 80;
  border-radius: 40;
  margin-top: 16;
  vertical-align: top;
  horizontal-align: center;
  background-color: #faf9f9;
}
.line {
  background-color: #f1f0f0;
  height: 1;
  margin: 0;
}
.checkbox {
  width: 18;
  height: 18;
  margin: 10 10 10 0;
}
.update {
  horizontal-align: stretch;
  text-align: center;
  color: white;
  background-color: #30bcff;
  height: 36;
}
.save {
  horizontal-align: stretch;
  text-align: center;
  color: white;
  background-color: #0011f8;
  height: 36;
}
.cancel {
  horizontal-align: stretch;
  text-align: center;
  color: white;
  background-color: #1b1c1d;
  height: 36;
}
</style>

Здесь есть несколько вещей, на которые стоит обратить внимание. Копия профиля из состояния создается и привязывается к странице для внесения изменений в эту копию. Это позволяет нам избежать прямой привязки элементов формы к переменной состояния, и любые сделанные изменения не будут окончательными, пока не будет нажата кнопка сохранения. Это один из двух популярных методов, используемых для подобных страниц в Vue. Когда дело доходит до изменения изображения профиля, будет использоваться другой подход путем перенаправления на новую страницу с копией данных изображения, которые необходимо обновить. Другой примечательный раздел кода касается сохранения документа обратно в Firestore. Наконец, изменения фиксируются в хранилище VueX для других страниц, использующих эти данные для рендеринга.

Теперь можно редактировать весь текст в коллекции профилей пользователей, но было бы гораздо лучше иметь возможность загружать новое изображение профиля через приложение, а не обновлять URL-адрес вручную. Давайте настроим приложение для использования нескольких новых плагинов, которые позволят пользователю сделать снимок или выбрать изображение. Затем потребуется некоторая интеграция с Firebase Storage, чтобы загрузить выбранное изображение и вернуть URL-адрес для документа профиля. (Примечание: пакет объявлений платформы используется для правильного сохранения локальных файлов изображений с помощью плагина выбора изображений из-за некоторых проблем в iOS для текущей версии плагина. )

Введите следующие команды:

tns plugin add nativescript-camera 
tns plugin add nativescript-imagepicker 
npm i tns-platform-declarations --save

Отредактируйте /firebase.nativescript.jsonи измените: "firestore": true. Затем запустите

rm -rf platforms && rm -rf node_modules && npm i 
tns run ios --bundle

Пока это перекомпилируется, перейдите в Консоль Firebase в разделе Хранилище и создайте новую папку /uploads/profile_pic/:

Это место в каталоге, куда будут загружены изображения для профиля пользователя. Добавьте следующий импорт в ProfilePage.vue: import ProfilePicture from "./ProfilePicture.vue"; Затем измените функцию onProfilePictureTapped(), чтобы она выглядела так:

onProfilePictureTapped() {
      if (this.editable) {
        this.$navigateTo(ProfilePicture);
      }
    },

Наконец, создайте /app/components/ProfilePicture.vue и добавьте:

<template>
    <Page actionBarHidden="true" backgroundSpanUnderStatusBar="true">
        <StackLayout>
            <GridLayout class="action-bar" rows="*" columns="50,2*,50">
                <Label col="0" row="0" class="fas" @tap="$navigateBack()" :text="'\uf060'"/>
                <Label col="1" row="0" class="header" text="Change Picture"/>               
            </GridLayout>         
            <Image id="image" :src="pictureSource" class="profile-picture-edit"/>
            <GridLayout columns="60,52,*" rows="50,10,50">
                <Label row="0" col="1" id="buttonCamera" :text="'\uf083'" @tap="takePicture" class="fas take-picture-icon "></Label>
                <Label row="0" col="2" text="Take a picture" class="desc-text"/>
                <Label row="2" col="1" id="buttonImage" :text="'\uf1c5'" @tap="chooseImage" class="fas take-picture-icon "></Label>
                <Label row="2" col="2" text="Choose an image" class="desc-text"/>
            </GridLayout>
            <GridLayout  columns="*,*"  >
                <Button text="Confirm" class="save" @tap="saveProfilePic" col="0"/>
                <Button text="Cancel" class="cancel" @tap="saveCancelPic" col="1"/>            
            </GridLayout>
        </StackLayout>
    </Page>    
</template>
<script>
import { mapState, mapGetters } from "vuex";
const cameraModule = require("nativescript-camera");
const imagepicker = require("nativescript-imagepicker");
const imageSourceModule = require("tns-core-modules/image-source");
import { isAndroid, isIOS } from "tns-core-modules/platform";
import firebase from "nativescript-plugin-firebase";
import { path, knownFolders } from "tns-core-modules/file-system";
var LoadingIndicator = require("nativescript-loading-indicator")
  .LoadingIndicator;
var loader = new LoadingIndicator();
const utilsModule = require("utils/utils");
var context = imagepicker.create({ mode: "single" }); // use "multiple" for multiple selection
export default {
  name: "picture-modal",
  data() {
    return {
      pictureSource: "",
      origSource: "",
      newFilename: ""
    };
  },
  components: {},
  computed: {
  },
  mounted() {
    this.pictureSource = this.$store.state.profile.profile_pic;
    this.origSource = this.$store.state.profile.profile_pic;
    if (cameraModule.isAvailable()) {
      //checks to make sure device has a camera
    } else {
      //ignore this on simulators for now
    }
    cameraModule.requestPermissions().then(
      //request permissions for camera
      success => {
        //have permissions
      },
      failure => {
        //no permissions for camera,disable picture button?        
      }
    );
  },
  methods: {
    takePicture() {
      cameraModule
        .takePicture({
          width: 300, //these are in device independent pixels
          height: 300, //only one will be respected depending on os/device if
          keepAspectRatio: true, //    keepAspectRatio is enabled.
          saveToGallery: false //Don't save a copy in local gallery, ignored by some Android devices
        })
        .then(picture => {
          //save to file
          imageSourceModule.fromAsset(picture).then(
            savedImage => {
              console.log("saving to file");
              let filename =
                this.$store.state.profile.id +
                "-" +
                new Date().getTime() +
                ".jpg";
              let folder = knownFolders.documents();
              let fullpath = path.join(folder.path, filename);
              savedImage.saveToFile(fullpath, "jpeg");
              //set the picture from the currently saved image path
              this.pictureSource = fullpath;
              this.newFilename = filename;
              if (isAndroid) {
                let tmpfolder = fsModule.Folder.fromPath(
                  utilsModule.ad
                    .getApplicationContext()
                    .getExternalFilesDir(null)
                    .getAbsolutePath()
                );
                tmpfolder.getEntities().then(
                  function(entities) {
                    entities.forEach(function(entity) {
                      if (entity.name.substr(0, 5) == "NSIMG") {
                        var tmpfile = tmpfolder.getFile(entity.name);
                        tmpfile.remove();
                      }
                    });
                  },
                  function(error) {
                    console.log(error.message);
                  }
                );
                utilsModule.GC(); //trigger garbage collection for android
              }
            },
            err => {
              console.log("Failed to load from asset");
            }
          );
        })
        .catch(err => {
          console.error(err);
        });
    },
    chooseImage() {
      try {
        context
          .authorize()
          .then(() => {
            return context.present();
          })
          .then(selection => {
            loader.show();
            const imageAsset = selection.length > 0 ? selection[0] : null;
            imageAsset.options = {
              width: 400,
              height: 400,
              keepAspectRatio: true
            };
            imageSourceModule
              .fromAsset(imageAsset)
              .then(imageSource => {
                let saved = false;
                let localPath = "";
                let filePath = "";
                let image = {};
                const folderPath = knownFolders.documents().path;
                let fileName =
                  this.$store.state.profile.id +
                  "-" +
                  new Date().getTime() +
                  ".jpg";
                if (imageAsset.android) {
                  localPath = imageAsset.android.toString().split("/");
                  fileName =
                    fileName +
                    "_" +
                    localPath[localPath.length - 1].split(".")[0] +
                    ".jpg";
                  filePath = path.join(folderPath, fileName);
                  saved = imageSource.saveToFile(filePath, "jpeg");
                  if (saved) {
                    this.pictureSource = imageAsset.android.toString();
                  } else {
                    console.log(
                      "Error! Unable to save pic to local file for saving"
                    );
                  }
                  loader.hide();
                } else {
                  const ios = imageAsset.ios;
                  if (ios.mediaType === PHAssetMediaType.Image) {
                    const opt = PHImageRequestOptions.new();
                    opt.version = PHImageRequestOptionsVersion.Current;
                    PHImageManager.defaultManager().requestImageDataForAssetOptionsResultHandler(
                      ios,
                      opt,
                      (imageData, dataUTI, orientation, info) => {
                        image.src = info
                          .objectForKey("PHImageFileURLKey")
                          .toString();
                        localPath = image.src.toString().split("/");
                        fileName =
                          fileName +
                          "_" +
                          localPath[localPath.length - 1].split(".")[0] +
                          ".jpeg";
                        filePath = path.join(folderPath, fileName);
                        saved = imageSource.saveToFile(filePath, "jpeg");
if (saved) {
                          console.log("saved picture to " + filePath);
                          this.pictureSource = filePath;
                        } else {
                          console.log(
                            "Error! Unable to save pic to local file for saving"
                          );
                        }
                        loader.hide();
                      }
                    );
                  }
                }
              })
              .catch(err => {
                console.log(err);
                loader.hide();
              });
          })
          .catch(err => {
            console.log(err);
            loader.hide();
          });
      } catch (err) {
        alert("Please select a valid image.");
        console.log(err)
        loader.hide();
      }
    },
    saveCancelPic() {
      this.pictureSource = this.origSource;
      this.$navigateBack();
    },
    saveProfilePic() {
      loader.show();
      if (this.pictureSource != this.origSource) {
        //upload this picture choice to firebase storage and get url
        //use this url for profile pic and save to firebase
        var filename = this.newFilename;
        firebase.storage
          .uploadFile({
            // the full path of the file in your Firebase storage (folders will be created)
            remoteFullPath: "uploads/profile_pic/" + filename,
            // option 2: a full file path (ignored if 'localFile' is set)
            localFullPath: this.pictureSource,
            // get notified of file upload progress
            onProgress: function(status) {
              console.log("Uploaded fraction: " + status.fractionCompleted);
              console.log("Percentage complete: " + status.percentageCompleted);
            }
          })
          .then(
            uploadedFile => {
              firebase.storage
                .getDownloadUrl({
                  // the full path of an existing file in your Firebase storage
                  remoteFullPath: "uploads/profile_pic/" + filename
                })
                .then(
                  url => {
                    this.pictureSource = url;
                    this.$store.commit("setProfilePicture", url);
                    loader.hide();
                    this.$navigateBack();
                  },
                  function(error) {
                    console.log("Error: " + error);
                    alert("Unable to update profile pic!");
                    loader.hide();
                  }
                )
                .catch(err => {
                  console.error(err);
                  alert("Unable to update profile pic!");
                  loader.hide();
                });
            },
            function(error) {
              alert("Unable to update profile pic!");
              console.log("File upload error: " + error);
              loader.hide();
            }
          )
          .catch(err => {
            alert("Unable to update profile pic!");
            console.error(err);
            loader.hide();
          });
      } else {
        console.log("No change in pic to save");
      }
    }
  }
};
</script>
<style scoped>
.take-picture-icon {
  horizontal-align: center;
  background-color: rgb(234, 234, 236);
  padding: 12;
  border-width: 1.2;
  border-color: black;
  border-radius: 14;
  margin-top: 20;
  color: black;
  font-size: 30;
  height:80;
  width:80;
}
.close-button {
  horizontal-align: stretch;
  text-align: center;
  color: white;
  background-color: #1b1c1d;
  height: 36;
}
.profile-picture-edit {
  width: 200;
  height: 200;
  border-width: 0.6;
  border-color: black;
  margin-top: 16;
  margin-bottom: 16;
  vertical-align: top;
  horizontal-align: center;
}
.save {
  horizontal-align: stretch;
  text-align: center;
  color: white;
  background-color: #0011f8;
  height: 36;
}
.cancel {
  horizontal-align: stretch;
  text-align: center;
  color: white;
  background-color: #1b1c1d;
  height: 36;
}
.desc-text{
  horizontal-align: left;
  vertical-align: center;
  margin-left:20;
  margin-top:20;
}
</style>

При этом используется новый мутатор для изменения только записи profile_pic в хранилище данных профиля путем добавления следующего к объекту mutations в /app/store/index.js:

setProfilePicture: (state, profilepicture) => {
    state.profile.profile_pic = profilepicture;
  },

Теперь вы сможете нажать на изображение профиля, чтобы перейти на новую страницу, где вы можете либо использовать камеру, либо выбрать изображение с устройства и загрузить его в Firebase для хранения и отображения в приложении. Эта страница нуждается в улучшении, и я планирую перенести процесс обновления изображения профиля на главную страницу профиля.

Сделанный!

Это все для этого поста. Я все еще изучаю Vue (и Nativescript), поэтому, если у кого-то, кто читает это, есть предложения, оставьте комментарий. Кроме того, если вы загрузите и улучшите этот демонстрационный проект, оставьте мне ссылку, чтобы я мог проверить его и поучиться у вас!

Если вы хотите скачать окончательные исходные файлы для быстрого старта, вы можете найти их на Github.

Первоначально опубликовано на сайте blog.angelengineering.com 28 октября 2018 г.