Browse Source

refactor: ajuste no registro inicial (sendo possivel selecionar ou tirar foto para o perfil) + ajuste na mensagem de sob medida (fix na imagem)

Gustavo Mantovani 2 weeks ago
parent
commit
bf2e12e4fb

+ 10 - 0
package-lock.json

@@ -10,6 +10,7 @@
       "dependencies": {
         "@bufbuild/protobuf": "^2.5.1",
         "@capacitor/browser": "^8.0.3",
+        "@capacitor/camera": "^7.0.5",
         "@capacitor/device": "^7.0.1",
         "@capacitor/geolocation": "^8.2.0",
         "@capacitor/google-maps": "^8.0.1",
@@ -325,6 +326,15 @@
         "@capacitor/core": ">=8.0.0"
       }
     },
+    "node_modules/@capacitor/camera": {
+      "version": "7.0.5",
+      "resolved": "https://registry.npmjs.org/@capacitor/camera/-/camera-7.0.5.tgz",
+      "integrity": "sha512-+53f1xHq8on7O+ULXbgWp00ooWvn3fUkWxTP3p5zHi1nVriCxLbYnY0zTpZQqn52M96vclFKkoABbTVQ3x755w==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@capacitor/core": ">=7.0.0"
+      }
+    },
     "node_modules/@capacitor/cli": {
       "version": "8.3.3",
       "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.3.3.tgz",

+ 1 - 0
package.json

@@ -24,6 +24,7 @@
   "dependencies": {
     "@bufbuild/protobuf": "^2.5.1",
     "@capacitor/browser": "^8.0.3",
+    "@capacitor/camera": "^7.0.5",
     "@capacitor/device": "^7.0.1",
     "@capacitor/geolocation": "^8.2.0",
     "@capacitor/google-maps": "^8.0.1",

+ 1 - 0
src-capacitor/android/app/capacitor.build.gradle

@@ -11,6 +11,7 @@ apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
 dependencies {
     implementation project(':capacitor-app')
     implementation project(':capacitor-browser')
+    implementation project(':capacitor-camera')
     implementation project(':capacitor-device')
     implementation project(':capacitor-geolocation')
     implementation project(':capacitor-google-maps')

+ 5 - 0
src-capacitor/android/app/src/main/AndroidManifest.xml

@@ -45,4 +45,9 @@
     <uses-permission android:name="android.permission.INTERNET" />
     <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
     <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
+    <uses-permission android:name="android.permission.CAMERA" />
+    <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
+    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
+
+    <uses-feature android:name="android.hardware.camera" android:required="false" />
 </manifest>

+ 3 - 0
src-capacitor/android/capacitor.settings.gradle

@@ -8,6 +8,9 @@ project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/
 include ':capacitor-browser'
 project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/browser/android')
 
+include ':capacitor-camera'
+project(':capacitor-camera').projectDir = new File('../node_modules/@capacitor/camera/android')
+
 include ':capacitor-device'
 project(':capacitor-device').projectDir = new File('../node_modules/@capacitor/device/android')
 

+ 6 - 0
src-capacitor/ios/App/App/Info.plist

@@ -45,5 +45,11 @@
 	</array>
 	<key>UIViewControllerBasedStatusBarAppearance</key>
 	<true/>
+	<key>NSCameraUsageDescription</key>
+	<string>Precisamos da câmera para você tirar sua foto de perfil.</string>
+	<key>NSPhotoLibraryUsageDescription</key>
+	<string>Precisamos de acesso às suas fotos para você escolher sua foto de perfil.</string>
+	<key>NSPhotoLibraryAddUsageDescription</key>
+	<string>Permite salvar fotos na sua galeria.</string>
 </dict>
 </plist>

+ 10 - 0
src-capacitor/package-lock.json

@@ -11,6 +11,7 @@
         "@capacitor/android": "^7.2.0",
         "@capacitor/app": "^7.0.0",
         "@capacitor/browser": "^8.0.3",
+        "@capacitor/camera": "^7.0.5",
         "@capacitor/cli": "^8.3.3",
         "@capacitor/core": "^7.0.0",
         "@capacitor/device": "^7.0.1",
@@ -50,6 +51,15 @@
         "@capacitor/core": ">=8.0.0"
       }
     },
+    "node_modules/@capacitor/camera": {
+      "version": "7.0.5",
+      "resolved": "https://registry.npmjs.org/@capacitor/camera/-/camera-7.0.5.tgz",
+      "integrity": "sha512-+53f1xHq8on7O+ULXbgWp00ooWvn3fUkWxTP3p5zHi1nVriCxLbYnY0zTpZQqn52M96vclFKkoABbTVQ3x755w==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@capacitor/core": ">=7.0.0"
+      }
+    },
     "node_modules/@capacitor/cli": {
       "version": "8.4.1",
       "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.4.1.tgz",

+ 1 - 0
src-capacitor/package.json

@@ -8,6 +8,7 @@
     "@capacitor/android": "^7.2.0",
     "@capacitor/app": "^7.0.0",
     "@capacitor/browser": "^8.0.3",
+    "@capacitor/camera": "^7.0.5",
     "@capacitor/cli": "^8.3.3",
     "@capacitor/core": "^7.0.0",
     "@capacitor/device": "^7.0.1",

+ 32 - 16
src/components/login/LoginStepOnePanel.vue → src/components/login/LoginStep1Panel.vue

@@ -1,56 +1,72 @@
 <template>
   <q-card-section class="full-width">
     <div class="text-text">
-      <span class="font12 fontbold">{{ $t('common.terms.email') }}</span>
+      <span class="font12 fontbold">{{ $t("common.terms.email") }}</span>
     </div>
+
     <q-input
       v-model="email"
+      class="bg-surface q-mt-sm"
+      hide-bottom-space
+      input-class="text-text font12 fontregular"
+      lazy-rules
       no-error-icon
       outlined
       rounded
-      class="bg-surface q-mt-sm"
-      input-class="text-text font12 fontregular"
+      :bottom-slots="false"
       :placeholder="$t('common.terms.email')"
-      hide-bottom-space
       :rules="!phone ? [inputRules.requiredHideMessage, inputRules.email] : []"
-      lazy-rules
-      :bottom-slots="false"
     />
 
     <div class="col-12 row q-py-lg">
       <div class="col-5 q-my-auto">
         <q-separator class="q-my-sm bg-grey-4" />
       </div>
-      <span class="col text-center text-text font12">{{ $t('common.ui.misc.or') }}</span>
+
+      <span class="col text-center text-text font12">
+        {{ $t("common.ui.misc.or") }}
+      </span>
+
       <div class="col-5 q-my-auto">
         <q-separator class="q-my-sm bg-grey-4" />
       </div>
     </div>
 
     <div class="text-text">
-      <span class="font12 fontbold">{{ $t('common.terms.phone') }}</span>
-      <span class="font12 fontregular">{{ ' (' + $t('common.terms.celular') + ')' }}</span>
+      <span class="font12 fontbold">{{ $t("common.terms.phone") }}</span>
+
+      <span class="font12 fontregular">
+        {{ " (" + $t("common.terms.celular") + ")" }}
+      </span>
     </div>
+
     <q-input
       v-model="phone"
-      no-error-icon
       class="bg-surface q-mt-sm"
-      :placeholder="$t('common.terms.phone')"
-      input-class="text-text font12 fontregular"
       hide-bottom-space
-      :rules="!email ? [inputRules.requiredHideMessage, inputRules.email] : []"
+      input-class="text-text font12 fontregular"
       lazy-rules
       mask="(##) #####-####"
+      no-error-icon
       :bottom-slots="false"
+      :placeholder="$t('common.terms.phone')"
+      :rules="!email ? [inputRules.requiredHideMessage, inputRules.email] : []"
     />
   </q-card-section>
 </template>
 
 <script setup>
-import { useInputRules } from 'src/composables/useInputRules';
+import { useInputRules } from "src/composables/useInputRules";
+
+const email = defineModel("email", {
+  required: true,
+  type: String,
+});
 
-const email = defineModel('email', { type: String, required: true });
-const phone = defineModel('phone', { type: String, required: true });
+const phone = defineModel("phone", {
+  required: true,
+  type: String,
+});
 
 const { inputRules } = useInputRules();
 </script>

+ 25 - 19
src/components/login/LoginStepTwoPanel.vue → src/components/login/LoginStep2Panel.vue

@@ -1,15 +1,15 @@
 <template>
   <q-card-section class="no-padding q-mb-xl">
     <div class="text-text q-mb-xs text-left">
-      <span class="font14 fontbold">{{ $t('auth.validation_code') }}</span>
+      <span class="font14 fontbold">{{ $t("auth.validation_code") }}</span>
     </div>
 
     <div class="code-boxes-wrapper" @click="focusCodeInput">
       <div
         v-for="i in 6"
         :key="i"
-        class="code-box"
         :class="{ 'code-box--active': code.length === i - 1 }"
+        class="code-box"
       >
         <span v-if="code[i - 1]" class="text-text">{{ code[i - 1] }}</span>
         <span v-else class="text-text-light text-h6">{{ placeholder }}</span>
@@ -19,50 +19,56 @@
     <q-input
       ref="codeInputRef"
       v-model="code"
-      no-error-icon
       class="code-hidden-input"
       hide-bottom-space
-      :rules="[inputRules.required]"
+      inputmode="numeric"
       lazy-rules
       mask="######"
-      inputmode="numeric"
+      no-error-icon
+      :rules="[inputRules.required]"
     />
   </q-card-section>
 </template>
 
 <script setup>
-import { ref } from 'vue';
-import { useInputRules } from 'src/composables/useInputRules';
+import { ref } from "vue";
+
+import { useInputRules } from "src/composables/useInputRules";
 
-const code = defineModel('code', { type: String, required: true });
+const code = defineModel("code", {
+  required: true,
+  type: String,
+});
 
 const { inputRules } = useInputRules();
+
 const codeInputRef = ref(null);
-const placeholder = 'x';
+
+const placeholder = "x";
 
 const focusCodeInput = () => {
-  codeInputRef.value?.$el.querySelector('input')?.focus();
+  codeInputRef.value?.$el.querySelector("input")?.focus();
 };
 </script>
 
 <style lang="scss" scoped>
 .code-boxes-wrapper {
+  cursor: text;
   display: flex;
   gap: 10px;
   justify-content: center;
-  cursor: text;
 
   .code-box {
-    width: 44px;
-    height: 45px; 
+    align-items: center;
+    background-color: #f9fafb;
     border: 2px solid #e4e4e4;
-    background-color: #F9FAFB;
     border-radius: 8px;
+    color: var(--q-text);
     display: flex;
-    align-items: center;
+    height: 45px;
     justify-content: center;
-    color: var(--q-text);
     transition: border-color 0.2s ease;
+    width: 44px;
 
     &--active {
       border-color: var(--q-primary);
@@ -71,11 +77,11 @@ const focusCodeInput = () => {
 }
 
 .code-hidden-input {
-  position: absolute;
+  height: 0;
+  overflow: hidden;
   opacity: 0;
   pointer-events: none;
+  position: absolute;
   width: 0;
-  height: 0;
-  overflow: hidden;
 }
 </style>

+ 38 - 31
src/components/login/LoginStepThreePanel.vue → src/components/login/LoginStep3Panel.vue

@@ -87,9 +87,8 @@
     <div class="row q-col-gutter-sm">
       <div class="col-4">
         <div class="text-text">
-          <span class="font14 fontbold">{{
-            $t("common.terms.address_number")
-          }}</span>
+          <span class="font14 fontbold">
+            {{ $t("common.terms.address_number") }}</span>
         </div>
 
         <q-input
@@ -177,9 +176,8 @@
     <div>
       <template v-if="!form.no_complement">
         <div class="text-text">
-          <span class="font14 fontbold">{{
-            $t("common.terms.complement")
-          }}</span>
+          <span class="font14 fontbold">
+            {{ $t("common.terms.complement") }}</span>
         </div>
 
         <q-input
@@ -219,9 +217,9 @@
 
     <div>
       <div class="text-text">
-        <span class="font14 fontbold">{{
-          $t("auth.address_instructions")
-        }}</span>
+        <span class="font14 fontbold">
+          {{ $t("auth.address_instructions") }}
+        </span>
       </div>
 
       <q-input
@@ -262,35 +260,44 @@
 </template>
 
 <script setup>
+import axios from "axios";
 import { ref } from "vue";
-import { useInputRules } from "src/composables/useInputRules";
 
-import axios from "axios";
+import { useInputRules } from "src/composables/useInputRules";
 
-const form = defineModel({ type: Object, required: true });
+const form = defineModel({
+  required: true,
+  type: Object,
+});
 
 const { inputRules } = useInputRules();
 
 const loadingCep = ref(false);
 
 const addressTypes = [
-  { value: "home", label: "auth.address_type_home", icon: "mdi-home-outline" },
   {
-    value: "commercial",
+    icon: "mdi-home-outline",
+    label: "auth.address_type_home",
+    value: "home",
+  },
+  {
+    icon: "mdi-briefcase-variant-outline",
     label: "auth.address_type_commercial",
-    icon:  "mdi-briefcase-variant-outline",
+    value: "commercial",
   },
   {
-    value: "other",
+    icon: "mdi-map-marker-outline",
     label: "auth.address_type_other",
-    icon:  "mdi-map-marker-outline",
+    value: "other",
   },
 ];
 
 const fetchCep = async (rawCep) => {
   const cleaned = rawCep.replace(/\D/g, "");
 
-  if (cleaned.length !== 8) return;
+  if (cleaned.length !== 8) {
+    return;
+  }
 
   loadingCep.value = true;
 
@@ -300,21 +307,21 @@ const fetchCep = async (rawCep) => {
     );
 
     if (!data.erro) {
-      form.value.address  = data.logradouro ?? "";
-      form.value.district = data.bairro     ?? "";
-      form.value.city     = data.localidade ?? "";
-      form.value.state    = data.uf         ?? "";
+      form.value.address = data.logradouro ?? "";
+      form.value.district = data.bairro ?? "";
+      form.value.city = data.localidade ?? "";
+      form.value.state = data.uf ?? "";
     } else {
-      form.value.address  = "";
+      form.value.address = "";
       form.value.district = "";
-      form.value.city     = "";
-      form.value.state    = "";
+      form.value.city = "";
+      form.value.state = "";
     }
   } catch {
-    form.value.address  = "";
+    form.value.address = "";
     form.value.district = "";
-    form.value.city     = "";
-    form.value.state    = "";
+    form.value.city = "";
+    form.value.state = "";
   } finally {
     loadingCep.value = false;
   }
@@ -326,10 +333,10 @@ const onCepChange = (val) => {
   if (cleaned.length === 8) {
     fetchCep(val);
   } else {
-    form.value.address  = "";
+    form.value.address = "";
     form.value.district = "";
-    form.value.city     = "";
-    form.value.state    = "";
+    form.value.city = "";
+    form.value.state = "";
   }
 };
 </script>

+ 57 - 34
src/components/login/LoginStepFourPanel.vue → src/components/login/LoginStep4Panel.vue

@@ -4,36 +4,36 @@
       <div class="bg-surface q-pa-lg">
         <div class="text-center q-mb-md">
           <div class="text-primary font14 fontbold">
-            {{ $t('auth.step4_title') }}
+            {{ $t("auth.step4_title") }}
           </div>
         </div>
 
         <q-input
           v-model="cep"
-          no-error-icon
-          outlined
-          rounded
           class="bg-surface q-mb-md"
-          input-class="text-text"
-          :placeholder="$t('common.terms.cep')"
           hide-bottom-space
+          input-class="text-text"
           lazy-rules
           mask="#####-###"
-          :loading="loadingCep"
+          no-error-icon
+          outlined
+          rounded
           :bottom-slots="false"
+          :loading="loadingCep"
+          :placeholder="$t('common.terms.cep')"
           @update:model-value="onCepChange"
         >
           <template #prepend>
-            <q-icon name="mdi-map-marker-outline" color="grey-5" />
+            <q-icon color="grey-5" name="mdi-map-marker-outline" />
           </template>
         </q-input>
 
         <q-btn
+          class="full-width"
           color="primary-button"
-          :label="$t('auth.use_location')"
-          rounded
           padding="8px 12px"
-          class="full-width"
+          rounded
+          :label="$t('auth.use_location')"
           :loading="loadingLocation"
           @click="useLocation"
         />
@@ -42,11 +42,11 @@
 
     <div class="text-center q-pt-sm">
       <q-btn
+        color="surface"
         flat
+        icon="mdi-chevron-left-circle-outline"
         rounded
-        color="surface"
         :label="$t('auth.back_to_register')"
-        :icon="'mdi-chevron-left-circle-outline'"
         @click="emit('back')"
       />
     </div>
@@ -54,37 +54,51 @@
 </template>
 
 <script setup>
-import { ref } from 'vue';
-import { useQuasar } from 'quasar';
-import { useI18n } from 'vue-i18n';
-import { useGeocodingApi } from 'src/composables/useGeocodingApi';
-import { useGeolocation } from 'src/composables/useGeolocation';
+import { ref } from "vue";
+import { useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
 
-const emit = defineEmits(['back', 'cep-resolved', 'device-location']);
+import { useGeocodingApi } from "src/composables/useGeocodingApi";
+import { useGeolocation } from "src/composables/useGeolocation";
+
+const emit = defineEmits(["back", "cep-resolved", "device-location"]);
 
 const $q = useQuasar();
 const { t } = useI18n();
 const { geocodeCep } = useGeocodingApi();
 const { requestPermission, getCurrentPosition } = useGeolocation();
 
-const cep = ref('');
+const cep = ref("");
 const loadingCep = ref(false);
 const loadingLocation = ref(false);
 
 const onCepChange = async (val) => {
-  const cleaned = val?.replace(/\D/g, '') ?? '';
-  if (cleaned.length !== 8) return;
+  const cleaned = val?.replace(/\D/g, "") ?? "";
+
+  if (cleaned.length !== 8) {
+    return;
+  }
 
   loadingCep.value = true;
+
   try {
     const result = await geocodeCep(cleaned);
+
     if (!result) {
-      $q.notify({ type: 'negative', message: t('auth.cep_not_found') });
+      $q.notify({
+        message: t("auth.cep_not_found"),
+        type: "negative",
+      });
+
       return;
     }
-    emit('cep-resolved', result);
+
+    emit("cep-resolved", result);
   } catch {
-    $q.notify({ type: 'negative', message: t('auth.cep_not_found') });
+    $q.notify({
+      message: t("auth.cep_not_found"),
+      type: "negative",
+    });
   } finally {
     loadingCep.value = false;
   }
@@ -92,22 +106,31 @@ const onCepChange = async (val) => {
 
 const useLocation = async () => {
   loadingLocation.value = true;
+
   try {
     const granted = await requestPermission();
+
     if (!granted) {
-      $q.notify({ type: 'warning', message: t('auth.location_permission_denied') });
+      $q.notify({
+        message: t("auth.location_permission_denied"),
+        type: "warning",
+      });
+
       return;
     }
+
     const position = await getCurrentPosition();
-    emit('device-location', position);
+
+    emit("device-location", position);
   } catch (err) {
-    // GeolocationPositionError.PERMISSION_DENIED = 1
-    const isPermissionError = err?.code === 1 || err?.message?.toLowerCase().includes('denied');
+    const isPermissionError =
+      err?.code === 1 || err?.message?.toLowerCase().includes("denied");
+
     $q.notify({
-      type: 'warning',
       message: isPermissionError
-        ? t('auth.location_permission_denied')
-        : t('auth.location_error'),
+        ? t("auth.location_permission_denied")
+        : t("auth.location_error"),
+      type: "warning",
     });
   } finally {
     loadingLocation.value = false;
@@ -117,10 +140,10 @@ const useLocation = async () => {
 
 <style lang="scss" scoped>
 .step4-card {
-  width: 100%;
-  max-width: 340px;
   border-radius: 30px;
+  max-width: 340px;
   position: relative;
+  width: 100%;
   z-index: 1;
 
   :deep(.q-field__marginal) {

+ 81 - 60
src/components/login/LoginStepAvatarPanel.vue

@@ -5,7 +5,7 @@
         <div
           class="photo-circle"
           :class="form.avatar ? 'photo-circle--done' : 'photo-circle--pending'"
-          @click="openDefaultPicker"
+          @click="openPicker"
         >
           <img
             v-if="avatarPreview"
@@ -24,44 +24,14 @@
           @change="onFileSelected"
         />
 
-        <input
-          ref="cameraInputRef"
-          accept="image/jpeg,image/png,image/webp"
-          capture="user"
-          class="hidden"
-          type="file"
-          @change="onFileSelected"
-        />
-
-        <template v-if="isNativeApp && !form.avatar">
-          <q-btn
-            color="primary-button"
-            no-caps
-            padding="10px 28px"
-            rounded
-            :label="$t('auth.avatar_step_take_photo')"
-            @click="cameraInputRef.click()"
-          />
-
-          <q-btn
-            color="primary"
-            flat
-            no-caps
-            padding="8px 16px"
-            rounded
-            :label="$t('auth.avatar_step_choose_photo')"
-            @click="uploadInputRef.click()"
-          />
-        </template>
-
         <q-btn
-          v-else-if="!form.avatar"
+          v-if="!form.avatar"
           color="primary-button"
           no-caps
           padding="10px 28px"
           rounded
           :label="$t('auth.avatar_step_button')"
-          @click="uploadInputRef.click()"
+          @click="openPicker"
         />
 
         <q-btn
@@ -84,36 +54,77 @@
 </template>
 
 <script setup>
+import { Camera, CameraResultType, CameraSource } from "@capacitor/camera";
 import { computed, onBeforeUnmount, ref, watch } from "vue";
 import { useQuasar } from "quasar";
 import { useI18n } from "vue-i18n";
 
-const form = defineModel({ type: Object, required: true });
-
 const $q = useQuasar();
 const { t } = useI18n();
 
+const form = defineModel({ type: Object, required: true });
+
 const uploadInputRef = ref(null);
-const cameraInputRef = ref(null);
 const avatarPreview = ref(null);
-const maxAvatarSize = 5 * 1024 * 1024;
-const acceptedAvatarTypes = ["image/jpeg", "image/png", "image/webp"];
 
 const isNativeApp = computed(() => {
   return $q.platform.is.capacitor || $q.platform.is.cordova;
 });
 
-const revokePreview = () => {
-  if (!avatarPreview.value) return;
+const maxAvatarSize = 5 * 1024 * 1024;
+const acceptedAvatarTypes = ["image/jpeg", "image/png", "image/webp"];
 
-  URL.revokeObjectURL(avatarPreview.value);
+const base64ToFile = (base64, filename, mimeType = "image/jpeg") => {
+  const byteString = atob(base64);
+  const ab = new ArrayBuffer(byteString.length);
+  const ia = new Uint8Array(ab);
 
-  avatarPreview.value = null;
+  for (let i = 0; i < byteString.length; i++) {
+    ia[i] = byteString.charCodeAt(i);
+  }
+
+  return new File([ab], filename, { type: mimeType });
 };
 
-const openDefaultPicker = () => {
+//
+
+const openNativePicker = async () => {
+  try {
+    const photo = await Camera.getPhoto({
+      resultType: CameraResultType.Base64,
+      source: CameraSource.Prompt,
+      quality: 85,
+      correctOrientation: true,
+      promptLabelHeader: t("auth.avatar_step_prompt_header"),
+      promptLabelPhoto: t("auth.avatar_step_prompt_gallery"),
+      promptLabelPicture: t("auth.avatar_step_prompt_camera"),
+      promptLabelCancel: t("auth.avatar_step_prompt_cancel"),
+    });
+
+    const base64 = photo.base64String.replace(/\s/g, "");
+    const mime = `image/${photo.format}`;
+    const file = base64ToFile(base64, `avatar.${photo.format}`, mime);
+
+    validateAndSetFile(file);
+  } catch (err) {
+    const msg = (err?.message || "").toLowerCase();
+
+    if (
+      !msg.includes("cancel") &&
+      !msg.includes("dismiss") &&
+      !msg.includes("no image")
+    ) {
+      $q.notify({
+        type: "negative",
+        message: t("auth.avatar_step_invalid"),
+      });
+    }
+  }
+};
+
+const openPicker = () => {
   if (isNativeApp.value) {
-    cameraInputRef.value?.click();
+    openNativePicker();
 
     return;
   }
@@ -121,9 +132,35 @@ const openDefaultPicker = () => {
   uploadInputRef.value?.click();
 };
 
+const revokePreview = () => {
+  if (!avatarPreview.value) return;
+
+  URL.revokeObjectURL(avatarPreview.value);
+
+  avatarPreview.value = null;
+};
+
+//
+
 const onFileSelected = (event) => {
   const file = event.target.files?.[0] ?? null;
 
+  validateAndSetFile(file);
+
+  event.target.value = "";
+};
+
+const removeAvatar = () => {
+  revokePreview();
+
+  form.value.avatar = null;
+
+  if (uploadInputRef.value) {
+    uploadInputRef.value.value = "";
+  }
+};
+
+const validateAndSetFile = (file) => {
   if (!file) return;
 
   if (!acceptedAvatarTypes.includes(file.type) || file.size > maxAvatarSize) {
@@ -132,8 +169,6 @@ const onFileSelected = (event) => {
       message: t("auth.avatar_step_invalid"),
     });
 
-    event.target.value = "";
-
     return;
   }
 
@@ -144,20 +179,6 @@ const onFileSelected = (event) => {
   avatarPreview.value = URL.createObjectURL(file);
 };
 
-const removeAvatar = () => {
-  revokePreview();
-
-  form.value.avatar = null;
-
-  if (uploadInputRef.value) {
-    uploadInputRef.value.value = "";
-  }
-
-  if (cameraInputRef.value) {
-    cameraInputRef.value.value = "";
-  }
-};
-
 watch(
   () => form.value.avatar,
   (avatar) => {

+ 4 - 0
src/i18n/locales/en.json

@@ -154,6 +154,10 @@
     "avatar_step_button": "Add photo",
     "avatar_step_take_photo": "Take photo",
     "avatar_step_choose_photo": "Choose photo",
+    "avatar_step_prompt_header": "Profile photo",
+    "avatar_step_prompt_gallery": "Choose from gallery",
+    "avatar_step_prompt_camera": "Take photo",
+    "avatar_step_prompt_cancel": "Cancel",
     "avatar_step_remove": "Remove photo",
     "avatar_step_hint": "Optional. You can continue without a photo.",
     "avatar_step_invalid": "Upload a JPG, PNG, or WEBP image up to 5 MB.",

+ 4 - 0
src/i18n/locales/es.json

@@ -154,6 +154,10 @@
     "avatar_step_button": "Agregar foto",
     "avatar_step_take_photo": "Tomar foto",
     "avatar_step_choose_photo": "Elegir foto",
+    "avatar_step_prompt_header": "Foto de perfil",
+    "avatar_step_prompt_gallery": "Elegir de la galería",
+    "avatar_step_prompt_camera": "Tomar foto",
+    "avatar_step_prompt_cancel": "Cancelar",
     "avatar_step_remove": "Eliminar foto",
     "avatar_step_hint": "Opcional. Puedes continuar sin foto.",
     "avatar_step_invalid": "Sube una imagen JPG, PNG o WEBP de hasta 5 MB.",

+ 4 - 0
src/i18n/locales/pt.json

@@ -154,6 +154,10 @@
     "avatar_step_button": "Cadastrar foto",
     "avatar_step_take_photo": "Tirar foto",
     "avatar_step_choose_photo": "Escolher foto",
+    "avatar_step_prompt_header": "Foto de perfil",
+    "avatar_step_prompt_gallery": "Escolher da galeria",
+    "avatar_step_prompt_camera": "Tirar foto",
+    "avatar_step_prompt_cancel": "Cancelar",
     "avatar_step_remove": "Remover foto",
     "avatar_step_hint": "Opcional. Você pode continuar sem foto.",
     "avatar_step_invalid": "Envie uma imagem JPG, PNG ou WEBP de até 5 MB.",

+ 17 - 11
src/pages/LoginPage.vue

@@ -44,7 +44,7 @@
         />
 
         <div class="step4-card-wrapper">
-          <LoginStepFourPanel
+          <LoginStep4Panel
             @back="goBack"
             @cep-resolved="onCepResolved"
             @device-location="onDeviceLocation"
@@ -60,6 +60,7 @@
         <div class="flow-header items-center row">
           <q-btn
             v-if="steps >= 4"
+            class="flow-back-btn"
             color="primary"
             dense
             flat
@@ -80,7 +81,7 @@
           />
         </div>
 
-        <div class="flow-logo">
+        <div class="flow-logo q-my-xl">
           <q-img
             style="max-width: 180px"
             :src="LogoDiariaCampos"
@@ -100,18 +101,18 @@
             class="flow-content"
             :class="{ 'flow-content--centered': steps < 3 }"
           >
-            <LoginStepOnePanel
+            <LoginStep1Panel
               v-if="steps === 1"
               v-model:email="email"
               v-model:phone="phone"
             />
 
-            <LoginStepTwoPanel
+            <LoginStep2Panel
               v-else-if="steps === 2"
               v-model:code="code"
             />
 
-            <LoginStepThreePanel
+            <LoginStep3Panel
               v-else-if="steps === 3"
               v-model="stepThreeForm"
             />
@@ -156,10 +157,10 @@ import LogoDiariaCampos from "src/assets/logo_diaria_campos_login.svg";
 import LogoLogin from "src/assets/logo_diaria_login.svg";
 
 import LoginStepAvatarPanel from "src/components/login/LoginStepAvatarPanel.vue";
-import LoginStepFourPanel from "src/components/login/LoginStepFourPanel.vue";
-import LoginStepOnePanel from "src/components/login/LoginStepOnePanel.vue";
-import LoginStepThreePanel from "src/components/login/LoginStepThreePanel.vue";
-import LoginStepTwoPanel from "src/components/login/LoginStepTwoPanel.vue";
+import LoginStep1Panel from "src/components/login/LoginStep1Panel.vue";
+import LoginStep2Panel from "src/components/login/LoginStep2Panel.vue";
+import LoginStep3Panel from "src/components/login/LoginStep3Panel.vue";
+import LoginStep4Panel from "src/components/login/LoginStep4Panel.vue";
 
 const { setAuthDataFromPayload } = useAuth();
 const flowStore = useRegistrationFlowStore();
@@ -427,12 +428,17 @@ onMounted(() => {
   min-height: 36px;
   display: flex;
   align-items: center;
+  padding-left: max(4px, env(safe-area-inset-left));
+}
+
+.flow-back-btn {
+  margin-left: -4px;
 }
 
 .flow-logo {
   display: flex;
   justify-content: center;
-  padding: 12px 0 24px;
+  padding: 12px 0 20px;
 }
 
 .flow-form {
@@ -454,4 +460,4 @@ onMounted(() => {
 .flow-footer {
   padding: 20px 0 calc(12px + env(safe-area-inset-bottom));
 }
-</style>
+</style>

+ 36 - 16
src/pages/schedules/components/FinalSuccesModal.vue

@@ -1,30 +1,45 @@
 <template>
-  <q-dialog ref="dialogRef" @hide="handleClose">
-    <q-card class="success-card bg-white">
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="success-card bg-white relative-position">
       <!-- fechar -->
       <q-btn
-        flat
-        round
+        class="absolute-top-right close-btn q-mt-sm q-mr-sm"
+        color="grey-5"
         dense
+        flat
         icon="mdi-close-circle-outline"
-        color="grey-5"
-        class="absolute-top-right q-mt-sm q-mr-sm"
-        @click="handleClose"
+        round
+        type="button"
+        @click.stop="handleClose"
       />
 
       <q-card-section class="text-center q-pt-lg q-pb-md">
         <!-- estrelas -->
         <div class="stars-wrapper">
-          <img src="/public/star1.svg" class="star-small" alt="estrela" />
-          <img src="/public/star.svg" class="star-big" alt="estrela" />
-          <img src="/public/star2.svg" class="star-small" alt="estrela" />
+          <img
+            alt="estrela"
+            class="star-small"
+            src="star1.svg"
+          />
+
+          <img
+            alt="estrela"
+            class="star-big"
+            src="star.svg"
+          />
+
+          <img
+            alt="estrela"
+            class="star-small"
+            src="star2.svg"
+          />
         </div>
 
         <!-- mascote -->
         <img
-          src="/public/diarinho-2.svg"
           alt="Mascote Sob Medida"
           class="success-image"
+          src="diarinho-2.svg"
         />
 
         <!-- titulo -->
@@ -36,7 +51,9 @@
         <!-- subtitulo -->
         <div class="success-subtitle q-mt-md">
           {{ $t('custom_schedule.success_subtitle_before') }}
+
           <strong>{{ $t('custom_schedule.success_subtitle_highlight') }}</strong>
+
           {{ $t('custom_schedule.success_subtitle_after') }}
         </div>
       </q-card-section>
@@ -51,13 +68,12 @@ defineEmits([...useDialogPluginComponent.emits])
 
 const {
   dialogRef,
-  onDialogOK
+  onDialogHide
 } = useDialogPluginComponent()
 
-function handleClose () {
-  onDialogOK()
+const handleClose = () => {
+  dialogRef.value?.hide()
 }
-console.log('abriu')
 </script>
 
 <style scoped lang="scss">
@@ -68,6 +84,10 @@ console.log('abriu')
   background: white !important;
 }
 
+.close-btn {
+  z-index: 2;
+}
+
 .stars-wrapper {
   display: flex;
   justify-content: center;
@@ -108,4 +128,4 @@ console.log('abriu')
   padding: 0 24px;
   margin-top: 14px;
 }
-</style>
+</style>