<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>