Newer
Older
navi-1 / webclient / src / components / settings / AppPanel.vue
<template>
  <div class="app-panel">
    <h2 class="app-title">{{ t('settings.appTitle') }}</h2>

    <p class="app-description">{{ t('settings.appDescription') }}</p>

    <!-- Deliberately a plain bordered block, not a kit GnCard: that one is a
         compact 340px dashboard tile, and the sibling settings panels are built
         the same way. -->
    <section class="app-card">
      <h3 class="app-card-title">
        <i class="ph ph-app-window"></i>
        <span>{{ t('settings.appInstallTitle') }}</span>
      </h3>

      <p class="app-text">{{ t('settings.appInstallText') }}</p>

      <GnAlert v-if="state === 'android-app'" variant="info" class="app-alert">
        <i class="ph ph-info"></i>
        <span>{{ t('settings.appAndroidAlert') }}</span>
      </GnAlert>

      <GnAlert v-else-if="state === 'installed'" variant="success" class="app-alert">
        <i class="ph ph-check-circle"></i>
        <span>{{ t('settings.appInstalledAlert') }}</span>
      </GnAlert>

      <div v-else-if="state === 'prompt'" class="app-action">
        <GnButton
          variant="primary"
          icon="ph ph-download-simple"
          :loading="installing"
          @click="onInstall"
        >
          {{ t('settings.appInstallAction') }}
        </GnButton>
        <span class="app-hint">{{ t('settings.appInstallHint') }}</span>
      </div>

      <!-- The instructions carry browser UI names and inline icons, so each
           sentence is split around them: the markup stays here, the words live
           in the dictionaries. -->
      <ol v-else class="app-steps">
        <li v-if="isIos">
          <b>{{ t('settings.appBrowserSafari') }}</b>
          {{ t('settings.appIosTap') }} <i class="ph ph-export"></i>
          {{ t('settings.appIosShare') }}
          <b>{{ t('settings.appIosAddHome') }}</b>. {{ t('settings.appIosNote') }}
        </li>
        <li v-else>
          <b>{{ t('settings.appBrowserChrome') }}</b>
          {{ t('settings.appChromeClick') }}
          <i class="ph ph-dots-three-vertical"></i> {{ t('settings.appChromeMenu') }}
          <b>{{ t('settings.appChromeInstall') }}</b>.
        </li>
        <li v-if="!isIos">
          <b>{{ t('settings.appBrowserAndroid') }}</b>
          {{ t('settings.appAndroidOpen') }}
          <i class="ph ph-dots-three-vertical"></i> {{ t('settings.appAndroidMenu') }}
          <b>{{ t('settings.appAndroidInstall') }}</b> {{ t('settings.appAndroidAlso') }}
        </li>
        <li>
          {{ t('settings.appHttpsBefore') }} <code>http://</code>
          {{ t('settings.appHttpsAfter') }}
        </li>
      </ol>
    </section>

    <section class="app-card">
      <h3 class="app-card-title">
        <i class="ph ph-android-logo"></i>
        <span>{{ t('settings.appAndroidTitle') }}</span>
      </h3>

      <p class="app-text">{{ t('settings.appAndroidText') }}</p>

      <p v-if="!apk" class="app-hint">
        {{ t('settings.appApkMissing') }}
        <code>android-client/tools/publish-apk.sh</code>.
      </p>

      <template v-else>
        <dl class="app-meta">
          <div><dt>{{ t('settings.appVersion') }}</dt><dd>{{ apk.versionName }}</dd></div>
          <div><dt>{{ t('settings.appSize') }}</dt><dd>{{ sizeText }}</dd></div>
          <div><dt>{{ t('settings.appBuilt') }}</dt><dd>{{ dateText }}</dd></div>
          <div class="app-meta-sha">
            <dt>SHA-256</dt>
            <dd><code>{{ apk.sha256 }}</code></dd>
          </div>
        </dl>

        <GnButton variant="primary" icon="ph ph-android-logo" @click="downloadApk">
          {{ t('settings.appDownloadApk') }}
        </GnButton>

        <ol class="app-steps app-steps-install">
          <li>{{ t('settings.appAndroidStep1') }}</li>
          <li>
            {{ t('settings.appAndroidStep2') }}
            <code>{{ defaultOrigin }}</code
            >{{ t('settings.appAndroidStep2Tail') }}
          </li>
          <li>{{ t('settings.appAndroidStep3') }}</li>
        </ol>
      </template>
    </section>
  </div>
</template>

<script setup>
import { computed, onMounted, ref } from 'vue'
import { GnAlert, GnButton } from 'gnexus-ui-kit/vue'
import { locale, t } from '@/i18n/index.js'
import { usePwa } from '@/composables/usePwa.js'

const { state, install, isIos } = usePwa()

const installing = ref(false)
const apk = ref(null)

// Where this page is served from is the address the Android app should be
// pointed at — showing it beats making the reader guess.
const defaultOrigin = window.location.origin

const sizeText = computed(() =>
  apk.value ? `${(apk.value.size / 1048576).toFixed(1)} MB` : ''
)

// The build date is read in the interface language, not the browser's: the
// two are different whenever the reader has chosen a language in Settings.
const dateText = computed(() =>
  apk.value
    ? new Date(apk.value.builtAt).toLocaleDateString(locale.value, {
        day: 'numeric',
        month: 'short',
        year: 'numeric',
      })
    : ''
)

onMounted(async () => {
  try {
    const res = await fetch('/download/navi-android.json')
    if (res.ok) apk.value = await res.json()
  } catch {
    // Offline, or the download directory was never published: the card says so.
  }
})

async function onInstall() {
  installing.value = true
  try {
    await install()
  } finally {
    installing.value = false
  }
}

function downloadApk() {
  // ?download=1 is not decoration: the Android app hands a same-host URL
  // carrying it to the system browser, and a WebView cannot install an APK.
  window.location.assign('/download/navi-android.apk?download=1')
}
</script>

<style scoped lang="scss">
.app-panel {
  max-width: 720px;
  margin-bottom: 24px;
}

.app-title {
  font-size: 1.1rem;
  font-weight: 600;
  margin: 0 0 12px;
  color: var(--gn-color-text-medium);
}

.app-description {
  margin: 0 0 16px;
  font-size: 0.875rem;
  color: var(--gn-color-text-medium);
}

.app-card {
  padding: 14px 16px;
  margin-bottom: 16px;
  border: 1px solid var(--gn-border-color-muted);
  border-radius: 8px;
}

.app-card-title {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 0 0 10px;
  font-size: 0.9rem;
  font-weight: 600;
  color: var(--gn-color-text-medium);
}

.app-text {
  margin: 0 0 12px;
  font-size: 0.875rem;
  color: var(--gn-color-text-medium);
}

.app-alert {
  display: flex;
  align-items: flex-start;
  gap: 8px;

  i {
    font-size: 16px;
    flex-shrink: 0;
  }
}

.app-action {
  display: flex;
  align-items: center;
  gap: 12px;
  flex-wrap: wrap;
}

.app-hint {
  margin: 0;
  font-size: 0.8rem;
  color: var(--gn-color-text-dark);
}

.app-steps {
  margin: 0;
  padding-left: 18px;
  font-size: 0.8rem;
  color: var(--gn-color-text-medium);

  li + li {
    margin-top: 6px;
  }

  code {
    font-size: 0.75rem;
    padding: 1px 4px;
    border-radius: 4px;
    background: var(--gn-color-surface-sunken, rgba(127, 127, 127, 0.12));
  }
}

.app-steps-install {
  margin-top: 14px;
}

.app-meta {
  margin: 0 0 14px;
  font-size: 0.8rem;
  color: var(--gn-color-text-dark);

  > div {
    display: flex;
    gap: 8px;
  }

  dt {
    min-width: 64px;
    color: var(--gn-color-text-medium);
  }

  dd {
    margin: 0;
  }
}

.app-meta-sha code {
  font-size: 0.7rem;
  word-break: break-all;
}
</style>