В предишна публикация описах как да създам приложение 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 от там, за да заредя страницата на таблото за управление, след като инициализацията завърши, но за разлика от някои други примерни приложения на Nativescript Vue, които разгледах, не можах да го накарам да работи. Вместо това ще настроя наблюдение на промените в данните, управлявани от 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 и изберете Database от главното меню:

Създайте Firestore база данни за приложението. Когато бъдете попитани за правилата за сигурност, изберете „тестов режим“, за да позволите достъп за четене и запис, без да се притеснявате за ограничения засега. Информацията за профила ще се съхранява в колекция с име /users/. Всеки потребител ще има данните за своя профил, съхранени в документ като /users/<UID>, със записи, индексирани от потребителския идентификатор на Firebase. Трябва да можете да видите този идентификатор в регистрационния файл на конзолата, след като влезете в приложението. Кликнете върху „Добавяне на колекция“ в конзолата на Firebase, за да създадете колекцията /users/ във Firestore за приложението.

На първата страница на модала задайте ID на колекцията като „потребители“. На втората страница добавете документа с идентификатор на потребителския идентификатор на Firebase, който сте видели след влизане с приложението. Добавете полета за id, name, bio, profile_pic и попълнете съответните стойности за всяко от тях. ID ще бъде същият потребителски 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 store. Редактирайте /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 Demo, което можете да изтеглите, за да видите как различни плъгини и дизайни могат да се използват в приложението 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 store за други страници, използващи тези данни за изобразяване.

Вече е възможно да редактирате целия текст в колекцията от потребителски профили, но би било много по-добре да можете да качите нова профилна снимка чрез приложението, вместо да актуализирате 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 Console под Storage и създайте нова папка /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 г.