Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c42bbc4239 | |||
| a9ffcbc134 | |||
| 91defd40fd | |||
| bde3bb4a57 | |||
| cc01f7df32 | |||
| 80de282e1a | |||
| 870b03c6c8 | |||
| 037e739807 | |||
| d02e58387d | |||
| a6796765a2 | |||
| 5a62aa4541 | |||
| 9790ad28e1 | |||
| 2ce44a12c5 | |||
| cb6149f8ba | |||
| 8963180006 | |||
| 5c37dd43c5 | |||
| 6f3c051c7c | |||
| 99c1a2d6c7 | |||
| a3b04887a1 | |||
| 8b6a65023f | |||
| 7572c610a6 | |||
| f567e15a99 | |||
| b1479d939a | |||
| 71913eb6e2 | |||
| 601ed66a38 | |||
| 9a69b092e8 | |||
| be211c22ed | |||
| 293b25466e | |||
| 1ba58e8b89 | |||
| ae0d4e4520 | |||
| c5765bbf15 | |||
| 9832d12751 | |||
| 14825df993 | |||
| e12be0a185 | |||
| 9f1f33213b | |||
| b2c029f02a | |||
| 915a1f64e6 |
@@ -34,10 +34,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
android:
|
||||
# Gated on a repository variable so the release can ship iOS alone while the
|
||||
# Google Play account and its signing secrets are not yet in place. Set the
|
||||
# repository variable NATIVE_RELEASE_ANDROID to 1 to turn the Android half on.
|
||||
if: github.repository == 'liketrek/TREK' && vars.NATIVE_RELEASE_ANDROID == '1'
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 40
|
||||
environment: store
|
||||
@@ -151,44 +148,44 @@ jobs:
|
||||
ASC_KEY_P8_B64: ${{ secrets.ASC_KEY_P8_B64 }}
|
||||
run: printf '%s' "$ASC_KEY_P8_B64" | base64 -d > "$RUNNER_TEMP/AuthKey.p8"
|
||||
|
||||
- name: Distribution certificate
|
||||
- name: Archive
|
||||
working-directory: client/ios/App
|
||||
env:
|
||||
APPLE_DIST_CERT_P12_B64: ${{ secrets.APPLE_DIST_CERT_P12_B64 }}
|
||||
run: printf '%s' "$APPLE_DIST_CERT_P12_B64" | base64 -d > "$RUNNER_TEMP/dist.p12"
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
||||
run: >-
|
||||
xcodebuild archive
|
||||
-project App.xcodeproj
|
||||
-scheme App
|
||||
-configuration Release
|
||||
-destination 'generic/platform=iOS'
|
||||
-archivePath "$RUNNER_TEMP/TREK.xcarchive"
|
||||
MARKETING_VERSION=${{ steps.version.outputs.version }}
|
||||
CURRENT_PROJECT_VERSION=${{ steps.version.outputs.build }}
|
||||
DEVELOPMENT_TEAM="$APPLE_TEAM_ID"
|
||||
-allowProvisioningUpdates
|
||||
-authenticationKeyPath "$RUNNER_TEMP/AuthKey.p8"
|
||||
-authenticationKeyID "$ASC_KEY_ID"
|
||||
-authenticationKeyIssuerID "$ASC_ISSUER_ID"
|
||||
|
||||
# Signs for distribution and uploads to TestFlight. Automatic signing cannot
|
||||
# run on a device-less CI runner (it signs for development and then needs a
|
||||
# development profile, which needs a registered device), so client/fastlane/Fastfile
|
||||
# imports the stored distribution certificate and creates the App Store profile
|
||||
# through the API key.
|
||||
# The tag's message, past its subject line, is what testers read under
|
||||
# "What to Test": one short line per change worth trying.
|
||||
- name: TestFlight notes from the tag
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
run: |
|
||||
git fetch --force --depth=1 origin "+${GITHUB_REF}:${GITHUB_REF}"
|
||||
{
|
||||
echo 'TESTFLIGHT_NOTES<<TREK_NOTES_END'
|
||||
# A signed tag carries its signature at the end of the message.
|
||||
git for-each-ref "$GITHUB_REF" --format='%(contents:body)' | sed '/-----BEGIN .*SIGNATURE-----/,$d'
|
||||
echo 'TREK_NOTES_END'
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build and upload to TestFlight
|
||||
working-directory: client
|
||||
# ExportOptions.plist says destination=upload, so this step sends the build
|
||||
# to App Store Connect, where it shows up in TestFlight after processing.
|
||||
- name: Export and upload to App Store Connect
|
||||
working-directory: client/ios/App
|
||||
env:
|
||||
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
||||
ASC_KEY_PATH: ${{ runner.temp }}/AuthKey.p8
|
||||
CERT_P12_PATH: ${{ runner.temp }}/dist.p12
|
||||
CERT_P12_PASSWORD: ${{ secrets.APPLE_DIST_CERT_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
MARKETING_VERSION: ${{ steps.version.outputs.version }}
|
||||
CURRENT_PROJECT_VERSION: ${{ steps.version.outputs.build }}
|
||||
FASTLANE_SKIP_UPDATE_CHECK: '1'
|
||||
FASTLANE_HIDE_CHANGELOG: '1'
|
||||
run: fastlane ios beta
|
||||
run: >-
|
||||
xcodebuild -exportArchive
|
||||
-archivePath "$RUNNER_TEMP/TREK.xcarchive"
|
||||
-exportOptionsPlist ../ExportOptions.plist
|
||||
-exportPath "$RUNNER_TEMP/export"
|
||||
-allowProvisioningUpdates
|
||||
-authenticationKeyPath "$RUNNER_TEMP/AuthKey.p8"
|
||||
-authenticationKeyID "$ASC_KEY_ID"
|
||||
-authenticationKeyIssuerID "$ASC_ISSUER_ID"
|
||||
|
||||
- name: Remove secrets
|
||||
- name: Remove the API key
|
||||
if: always()
|
||||
run: rm -f "$RUNNER_TEMP/AuthKey.p8" "$RUNNER_TEMP/dist.p12"
|
||||
run: rm -f "$RUNNER_TEMP/AuthKey.p8"
|
||||
|
||||
@@ -17,7 +17,6 @@ on:
|
||||
- 'client/ios/**'
|
||||
- 'client/native-shell/**'
|
||||
- 'client/src/native/trekShell.ts'
|
||||
- 'client/src/native/watch.ts'
|
||||
- 'client/src/native/serverAddress.ts'
|
||||
- 'client/capacitor.config.ts'
|
||||
- 'client/vite.native.config.js'
|
||||
@@ -81,7 +80,7 @@ jobs:
|
||||
# unsigned: the upload key only exists in native-release.yml.
|
||||
- name: Assemble debug and release APKs
|
||||
working-directory: client/android
|
||||
run: ./gradlew assembleDebug assembleRelease lintDebug testDebugUnitTest --no-daemon
|
||||
run: ./gradlew assembleDebug assembleRelease lintDebug --no-daemon
|
||||
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
@@ -104,7 +103,7 @@ jobs:
|
||||
xcodebuild -version
|
||||
|
||||
- name: Check the property lists
|
||||
run: plutil -lint client/ios/App/App/Info.plist client/ios/ExportOptions.plist client/ios/App/NotificationService/Info.plist client/ios/App/NotificationService/NotificationService.entitlements client/ios/App/App/App.entitlements client/ios/App/TREKWidgets/Info.plist client/ios/App/TREKWidgets/TREKWidgets.entitlements client/ios/App/TREKWidgets/*.lproj/Localizable.strings client/ios/App/ShareExtension/Info.plist client/ios/App/ShareExtension/ShareExtension.entitlements client/ios/App/ShareExtension/*.lproj/Localizable.strings client/ios/App/TREKWatch/Info.plist client/ios/App/TREKWatch/TREKWatch.entitlements client/ios/App/TREKWatch/*.lproj/Localizable.strings client/ios/App/TREKWatchWidgets/Info.plist client/ios/App/TREKWatchWidgets/TREKWatchWidgets.entitlements
|
||||
run: plutil -lint client/ios/App/App/Info.plist client/ios/ExportOptions.plist
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -121,8 +120,6 @@ jobs:
|
||||
working-directory: client
|
||||
run: npm run cap:sync -- ios
|
||||
|
||||
# No -sdk: the scheme builds the embedded watch app and its complications
|
||||
# against the watchOS simulator SDK and the rest against the iOS one.
|
||||
- name: Build for the simulator
|
||||
working-directory: client/ios/App
|
||||
run: |
|
||||
@@ -130,113 +127,6 @@ jobs:
|
||||
-project App.xcodeproj \
|
||||
-scheme App \
|
||||
-configuration Debug \
|
||||
-sdk iphonesimulator \
|
||||
-destination 'generic/platform=iOS Simulator' \
|
||||
CODE_SIGNING_ALLOWED=NO
|
||||
|
||||
# The widgets drawn to PNGs (client/ios/WidgetPreviews): every size, light and
|
||||
# dark, for a trip ahead, a trip under way and none, plus a home screen and
|
||||
# a lock screen of them. Nobody has to install a build to see a widget change.
|
||||
widget-previews:
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Select Xcode 26
|
||||
run: |
|
||||
XCODE=$(ls -d /Applications/Xcode_26*.app | sort -V | tail -1)
|
||||
test -n "$XCODE"
|
||||
sudo xcode-select -s "$XCODE"
|
||||
|
||||
- name: Render the widgets
|
||||
working-directory: client/ios/WidgetPreviews
|
||||
run: |
|
||||
mkdir -p Sources/WidgetPreviews/Widgets
|
||||
cp ../App/TREKWidgets/WidgetSnapshot.swift ../App/TREKWidgets/TrekStyle.swift ../App/TREKWidgets/TrekFamily.swift ../App/TREKWidgets/WorldGrid.swift \
|
||||
../App/TREKWidgets/*Content.swift Sources/WidgetPreviews/Widgets/
|
||||
swift run -c release WidgetPreviews out "$PWD/../App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png"
|
||||
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: widget-previews
|
||||
path: client/ios/WidgetPreviews/out
|
||||
retention-days: 14
|
||||
|
||||
# The watch's half in the UI (client/src/native/watch.ts): the snapshot it
|
||||
# builds and the ticks it takes back, typechecked and tested here as well,
|
||||
# so a change to the bridge is checked next to the Swift that reads it.
|
||||
watch-bridge:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
||||
- name: Install
|
||||
run: npm ci --workspace=shared --workspace=client
|
||||
|
||||
- name: Build shared
|
||||
run: npm run build --workspace=shared
|
||||
|
||||
- name: Typecheck the client
|
||||
working-directory: client
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Test the widget and watch snapshots
|
||||
working-directory: client
|
||||
run: npx vitest run src/native/watch.test.ts src/native/widgets.test.ts src/native/widgetExtras.test.ts
|
||||
|
||||
# The watch app on watchOS simulators in its screenshot mode (DemoMode.swift):
|
||||
# every screen on the largest and the smallest watch, the main ones in every
|
||||
# language, and the complications, as PNGs to look at without a watch.
|
||||
watch-screens:
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Select Xcode 26
|
||||
run: |
|
||||
XCODE=$(ls -d /Applications/Xcode_26*.app | sort -V | tail -1)
|
||||
test -n "$XCODE"
|
||||
sudo xcode-select -s "$XCODE"
|
||||
|
||||
# The project resolves the Capacitor packages from node_modules, even for
|
||||
# a target that uses none of them.
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
||||
- name: Install
|
||||
run: npm ci --workspace=shared --workspace=client
|
||||
|
||||
- name: Make sure a watchOS simulator runtime is there
|
||||
run: |
|
||||
xcrun simctl list runtimes | grep -q watchOS || xcodebuild -downloadPlatform watchOS
|
||||
xcrun simctl list runtimes
|
||||
|
||||
- name: Build the watch app for the simulator
|
||||
working-directory: client/ios/App
|
||||
run: |
|
||||
xcodebuild build \
|
||||
-project App.xcodeproj \
|
||||
-target TREKWatch \
|
||||
-configuration Debug \
|
||||
-sdk watchsimulator \
|
||||
SYMROOT="$PWD/build" \
|
||||
CODE_SIGNING_ALLOWED=NO
|
||||
|
||||
- name: Capture the screens
|
||||
run: bash client/ios/scripts/watch-screens.sh client/ios/App/build/Debug-watchsimulator/TREKWatch.app "$PWD/watch-screens"
|
||||
|
||||
- uses: actions/upload-artifact@v6
|
||||
if: always()
|
||||
with:
|
||||
name: watch-screens
|
||||
path: watch-screens
|
||||
retention-days: 14
|
||||
|
||||
@@ -56,5 +56,5 @@ See `values.yaml` for more options.
|
||||
- `COOKIE_SECURE` is auto-derived (on when `NODE_ENV=production` or `FORCE_HTTPS=true`). Set `env.COOKIE_SECURE: "false"` only during local testing without TLS. **Not recommended for production.**
|
||||
- Web Push needs no chart settings: TREK generates its key pair on first start and keeps it in the database on the data PVC. To bring your own pair, set `env.VAPID_PUBLIC_KEY` and `secretEnv.VAPID_PRIVATE_KEY` together (and optionally `env.VAPID_SUBJECT`); changing the pair later means every device has to turn push on again.
|
||||
- Set `env.OIDC_DISCOVERY_URL` to override the auto-constructed OIDC discovery endpoint. Required for providers (e.g. Authentik) that expose it at a non-standard path.
|
||||
- `podSecurityContext` defaults to `fsGroup: 1000` with `fsGroupChangePolicy: OnRootMismatch`, and `securityContext` (the container's) is empty. The container then starts as root, hands `/app/data` and `/app/uploads` to `node` (uid 1000) if the directory or anything directly inside it is not owned by `node` yet, and drops to `node`. Deeper files are not checked. Only the owning user is changed (plus a `root` group), so a different `fsGroup` stays in place. A volume that refuses `chown` (NFS with `root_squash`) logs a warning instead; set `runAsUser: 1000` then. To run as `node` from the start, for example under the `restricted` Pod Security Standard, set `podSecurityContext.runAsUser: 1000` and `runAsGroup: 1000`; the image then skips that step and the privilege drop. See the example in `values.yaml`.
|
||||
- `podSecurityContext` defaults to `fsGroup: 1000` with `fsGroupChangePolicy: OnRootMismatch`, and `securityContext` (the container's) is empty. The container then starts as root, hands any file under `/app/data` and `/app/uploads` that is not owned by `node` yet to `node`, and drops to `node`. To run as `node` from the start, for example under the `restricted` Pod Security Standard, set `podSecurityContext.runAsUser: 1000` and `runAsGroup: 1000`; the image then skips that step and the privilege drop. See the example in `values.yaml`.
|
||||
- Use `extraVolumes` and `extraVolumeMounts` to add your own volumes to the pod. To give the booking import more KItinerary extractors, mount a ConfigMap at `/usr/local/share/kitinerary/extractors`, with one `.json` and one `.js` key for each extractor; see the example in `values.yaml`.
|
||||
|
||||
@@ -127,9 +127,6 @@ data:
|
||||
{{- if .Values.env.TREK_VALHALLA_URL }}
|
||||
TREK_VALHALLA_URL: {{ .Values.env.TREK_VALHALLA_URL | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.TREK_WALLET_SIGNER_URL }}
|
||||
TREK_WALLET_SIGNER_URL: {{ .Values.env.TREK_WALLET_SIGNER_URL | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.GOOGLE_LIST_DATA_BASE }}
|
||||
GOOGLE_LIST_DATA_BASE: {{ .Values.env.GOOGLE_LIST_DATA_BASE | quote }}
|
||||
{{- end }}
|
||||
|
||||
@@ -159,10 +159,6 @@ env:
|
||||
# TREK_VALHALLA_URL: ""
|
||||
# The Valhalla of the TREK services, which this server relays routing to unless the admin picks another
|
||||
# routing service. The browser never talks to it. Defaults to https://valhalla.liketrek.com.
|
||||
# TREK_WALLET_SIGNER_URL: ""
|
||||
# Signs the Apple Wallet pass of a booking when someone taps "Add to Apple Wallet" in the iOS app.
|
||||
# Only the pass's texts go there, once per tap. Never asked with TREK_SERVICES=off.
|
||||
# Defaults to https://push.liketrek.com/v1/wallet/pass.
|
||||
# AMAP_API_BASE: ""
|
||||
# Send the Amap (高德地图) calls through an egress proxy, a cache or a gateway that speaks the same API instead of
|
||||
# restapi.amap.com directly. The key itself is a credential and goes under secretEnv.AMAP_API_KEY, or into
|
||||
|
||||
@@ -106,6 +106,3 @@ app/src/main/assets/public
|
||||
app/src/main/assets/capacitor.config.json
|
||||
app/src/main/assets/capacitor.plugins.json
|
||||
app/src/main/res/xml/config.xml
|
||||
|
||||
# Kotlin compiler caches
|
||||
.kotlin/
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'org.jetbrains.kotlin.android'
|
||||
apply plugin: 'org.jetbrains.kotlin.plugin.compose'
|
||||
|
||||
android {
|
||||
namespace = "com.liketrek.trek"
|
||||
@@ -34,25 +32,6 @@ android {
|
||||
}
|
||||
}
|
||||
}
|
||||
// java.time in the widgets and the travel-day notification, back to Android 7.
|
||||
compileOptions {
|
||||
coreLibraryDesugaringEnabled true
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
// The app's languages, for the system's per-app language setting (Android 13+):
|
||||
// generated from the values-* folders, so a language added there is offered too.
|
||||
androidResources {
|
||||
generateLocaleConfig = true
|
||||
}
|
||||
testOptions {
|
||||
unitTests {
|
||||
includeAndroidResources = true
|
||||
returnDefaultValues = true
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
if (project.hasProperty('trekKeystore')) {
|
||||
@@ -77,40 +56,11 @@ dependencies {
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation "androidx.browser:browser:$androidxBrowserVersion"
|
||||
implementation "androidx.biometric:biometric:1.1.0"
|
||||
implementation "androidx.core:core-ktx:$androidxCoreVersion"
|
||||
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.1.5"
|
||||
// Home-screen widgets.
|
||||
implementation "androidx.glance:glance-appwidget:$glanceVersion"
|
||||
implementation "androidx.glance:glance-material3:$glanceVersion"
|
||||
// The share sheet and the widget's trip picker.
|
||||
implementation platform("androidx.compose:compose-bom:$composeBomVersion")
|
||||
implementation "androidx.compose.material3:material3"
|
||||
implementation "androidx.compose.material:material-icons-extended"
|
||||
implementation "androidx.compose.ui:ui-tooling-preview"
|
||||
debugImplementation "androidx.compose.ui:ui-tooling"
|
||||
implementation "androidx.activity:activity-compose:$androidxActivityVersion"
|
||||
// A foldable's posture and hinge, and the window's size, for the UI.
|
||||
implementation "androidx.window:window:1.4.0"
|
||||
// Ranks the trip shortcuts in the share sheet on Android 10 and earlier.
|
||||
implementation "androidx.sharetarget:sharetarget:1.2.0"
|
||||
// TREK Cloud subscriptions through Google Play, the counterpart of StoreKit.
|
||||
implementation "com.android.billingclient:billing:$playBillingVersion"
|
||||
// Push through liketrek's relay. No google-services plugin: Firebase reads
|
||||
// its project from res/values/firebase.xml, the strings the plugin would generate.
|
||||
implementation "com.google.firebase:firebase-messaging:24.1.2"
|
||||
implementation project(':capacitor-android')
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
testImplementation "org.json:json:20250517"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
implementation project(':capacitor-cordova-android-plugins')
|
||||
}
|
||||
|
||||
apply from: 'capacitor.build.gradle'
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Debug builds only: the widget gallery (debug/WidgetGalleryActivity.kt). -->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<activity
|
||||
android:name=".debug.WidgetGalleryActivity"
|
||||
android:theme="@style/AppTheme.NoActionBar"
|
||||
android:exported="true" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -1,125 +0,0 @@
|
||||
package com.liketrek.trek.debug
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.util.TypedValue
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi
|
||||
import androidx.glance.appwidget.GlanceRemoteViews
|
||||
import com.liketrek.trek.tripday.TripDayNotification
|
||||
import com.liketrek.trek.widget.AtlasWidget
|
||||
import com.liketrek.trek.widget.BookingsWidget
|
||||
import com.liketrek.trek.widget.CollabWidget
|
||||
import com.liketrek.trek.widget.PackingWidget
|
||||
import com.liketrek.trek.widget.TodayWidget
|
||||
import com.liketrek.trek.widget.TodosWidget
|
||||
import com.liketrek.trek.widget.TrekSizes
|
||||
import com.liketrek.trek.widget.TrekWidget
|
||||
import com.liketrek.trek.widget.TrekWidgetTheme
|
||||
import com.liketrek.trek.widget.TripWidget
|
||||
import com.liketrek.trek.widget.WidgetSamples
|
||||
import com.liketrek.trek.widget.WidgetStore
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Debug builds only: every widget at every size, drawn by the real Glance code
|
||||
* into real RemoteViews, so they can be looked at and screenshot without placing
|
||||
* each on a home screen.
|
||||
*
|
||||
* adb shell am start -n com.liketrek.trek/.debug.WidgetGalleryActivity --es widget trip --ez running true --ez dark false
|
||||
*
|
||||
* --es seed sample also writes the sample trip as the app's snapshot, so the
|
||||
* placed widgets, the share sheet and the shortcuts have a trip to show; and
|
||||
* --ez tripday true posts the travel day's Live Update from the sample.
|
||||
*/
|
||||
@OptIn(ExperimentalGlanceRemoteViewsApi::class)
|
||||
class WidgetGalleryActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val running = intent.getBooleanExtra("running", true)
|
||||
val dark = intent.getBooleanExtra("dark", false)
|
||||
val only = intent.getStringExtra("widget")
|
||||
if (intent.getStringExtra("seed") == "sample") seed()
|
||||
if (intent.getBooleanExtra("tripday", false)) {
|
||||
TripDayNotification.show(
|
||||
this,
|
||||
TripDayNotification.State(
|
||||
"Autumn in Kyoto", "/trips/1", "Day 3 of 9", "Kiyomizu-dera", "13:30", 810, 2, 5,
|
||||
listOf("#D6273B", "#E8A13A", "#2FA37A", "#4A7DDB", "#8E6CD9"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val config = Configuration(resources.configuration).apply {
|
||||
uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or if (dark) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO
|
||||
}
|
||||
val themed = createConfigurationContext(config)
|
||||
val list = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(px(16), px(if (intent.getBooleanExtra("bare", false)) 96 else 32), px(16), px(32))
|
||||
val bg = intent.getStringExtra("bg")
|
||||
setBackgroundColor(if (bg != null) Color.parseColor("#$bg") else if (dark) Color.rgb(28, 27, 31) else Color.rgb(222, 226, 236))
|
||||
}
|
||||
setContentView(ScrollView(this).apply { addView(list) })
|
||||
|
||||
val widgets: List<Pair<String, TrekWidget>> = listOf(
|
||||
"trip" to TripWidget(),
|
||||
"today" to TodayWidget(),
|
||||
"bookings" to BookingsWidget(),
|
||||
"todos" to TodosWidget(),
|
||||
"packing" to PackingWidget(),
|
||||
"atlas" to AtlasWidget(),
|
||||
"collab" to CollabWidget(),
|
||||
).filter { only == null || it.first == only }
|
||||
// --ez store true draws what the app really wrote, instead of the sample trip.
|
||||
val snapshot = if (intent.getBooleanExtra("store", false)) WidgetStore.load(this) else WidgetSamples.snapshot(running)
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
for ((name, widget) in widgets) {
|
||||
if (!intent.getBooleanExtra("bare", false)) list.addView(TextView(this@WidgetGalleryActivity).apply {
|
||||
text = name
|
||||
setTextColor(if (dark) Color.WHITE else Color.DKGRAY)
|
||||
setPadding(0, px(12), 0, px(6))
|
||||
})
|
||||
val onlySize = intent.getStringExtra("size")
|
||||
val sizes = listOf("small" to TrekSizes.SMALL, "wide" to TrekSizes.WIDE, "large" to TrekSizes.LARGE)
|
||||
for ((sizeName, size) in sizes) {
|
||||
if (onlySize != null && onlySize != sizeName) continue
|
||||
val result = GlanceRemoteViews().compose(themed, size) {
|
||||
TrekWidgetTheme.Themed { widget.Content(snapshot) }
|
||||
}
|
||||
val host = FrameLayout(this@WidgetGalleryActivity)
|
||||
host.addView(result.remoteViews.apply(themed, host), FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
||||
list.addView(host, LinearLayout.LayoutParams(px(size.width.value), px(size.height.value)).apply { bottomMargin = px(12) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun seed() {
|
||||
WidgetStore.write(this, "snapshot", WidgetSamples.json(running = true))
|
||||
WidgetStore.write(this, "trip.1", WidgetSamples.json(running = true))
|
||||
WidgetStore.write(this, "trip.2", WidgetSamples.json(running = false).replace("Autumn in Kyoto", "Lisbon long weekend"))
|
||||
WidgetStore.write(
|
||||
this,
|
||||
"trips",
|
||||
"""[{"id":1,"title":"Autumn in Kyoto","start":"${java.time.LocalDate.now().minusDays(2)}","end":"${java.time.LocalDate.now().plusDays(6)}"},
|
||||
{"id":2,"title":"Lisbon long weekend","start":"${java.time.LocalDate.now().plusDays(12)}","end":"${java.time.LocalDate.now().plusDays(15)}"},
|
||||
{"id":3,"title":"Norway by train","start":"2026-06-02","end":"2026-06-12"}]""",
|
||||
)
|
||||
com.liketrek.trek.widget.TrekWidgetUpdater.refreshAll(this)
|
||||
com.liketrek.trek.share.TrekShortcuts.publish(this)
|
||||
}
|
||||
|
||||
private fun px(dp: Float): Int = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, resources.displayMetrics).toInt()
|
||||
|
||||
private fun px(dp: Int): Int = px(dp.toFloat())
|
||||
|
||||
}
|
||||
@@ -9,33 +9,21 @@
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:resizeableActivity="true"
|
||||
android:theme="@style/AppTheme">
|
||||
|
||||
<!-- Every window shape a tablet, a foldable, ChromeOS or desktop windowing
|
||||
offers. The UI lays itself out by the window's size (hooks/sizeClass.ts),
|
||||
so the activity takes size, fold and orientation changes itself
|
||||
(configChanges) instead of restarting and losing the page. -->
|
||||
<meta-data android:name="android.supports_size_changes" android:value="true" />
|
||||
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|smallestScreenSize|screenLayout|uiMode|navigation|density|fontScale|touchscreen|colorMode|fontWeightAdjustment"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
|
||||
android:name=".MainActivity"
|
||||
android:label="@string/title_activity_main"
|
||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||
android:launchMode="singleTask"
|
||||
android:screenOrientation="fullUser"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:exported="true">
|
||||
|
||||
<!-- The trips as launcher and direct-share shortcuts (TrekShortcuts). -->
|
||||
<meta-data android:name="android.app.shortcuts" android:resource="@xml/shortcuts" />
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Deep links.
|
||||
|
||||
The scheme is @string/custom_url_scheme (com.liketrek.trek).
|
||||
@@ -62,131 +50,6 @@
|
||||
|
||||
</activity>
|
||||
|
||||
<!-- "TREK" in the share sheet and "Open with TREK": a booking document, a
|
||||
mail, a page or a link, into a trip (share/ShareActivity.kt). -->
|
||||
<activity
|
||||
android:name=".share.ShareActivity"
|
||||
android:theme="@style/AppTheme.Sheet"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
|
||||
android:excludeFromRecents="true"
|
||||
android:taskAffinity="com.liketrek.trek.share"
|
||||
android:launchMode="standard"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="application/pdf" />
|
||||
<data android:mimeType="application/vnd.apple.pkpass" />
|
||||
<data android:mimeType="message/rfc822" />
|
||||
<data android:mimeType="text/calendar" />
|
||||
<data android:mimeType="text/html" />
|
||||
<data android:mimeType="text/plain" />
|
||||
<data android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="application/pdf" />
|
||||
<data android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<!-- content:// only: a file:// path would be read with TREK's own rights. -->
|
||||
<data android:scheme="content" />
|
||||
<data android:mimeType="application/pdf" />
|
||||
<data android:mimeType="text/calendar" />
|
||||
<data android:mimeType="application/vnd.apple.pkpass" />
|
||||
<data android:mimeType="message/rfc822" />
|
||||
</intent-filter>
|
||||
<!-- Lets the share sheet rank the trip shortcuts for this target. -->
|
||||
<meta-data android:name="android.service.chooser.chooser_target_service" android:value="androidx.sharetarget.ChooserTargetServiceCompat" />
|
||||
</activity>
|
||||
|
||||
<!-- "Choose trip" for a placed widget. -->
|
||||
<activity
|
||||
android:name=".widget.WidgetConfigActivity"
|
||||
android:theme="@style/AppTheme.Native"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- The home-screen widgets (widget/TrekWidgets.kt). -->
|
||||
<receiver
|
||||
android:name=".widget.TripWidgetReceiver"
|
||||
android:label="@string/widget_trip_name"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_trip" />
|
||||
</receiver>
|
||||
<receiver
|
||||
android:name=".widget.TodayWidgetReceiver"
|
||||
android:label="@string/widget_today_name"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_today" />
|
||||
</receiver>
|
||||
<receiver
|
||||
android:name=".widget.BookingsWidgetReceiver"
|
||||
android:label="@string/widget_bookings_name"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_bookings" />
|
||||
</receiver>
|
||||
<receiver
|
||||
android:name=".widget.TodosWidgetReceiver"
|
||||
android:label="@string/widget_todos_name"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_todos" />
|
||||
</receiver>
|
||||
<receiver
|
||||
android:name=".widget.PackingWidgetReceiver"
|
||||
android:label="@string/widget_packing_name"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_packing" />
|
||||
</receiver>
|
||||
<receiver
|
||||
android:name=".widget.AtlasWidgetReceiver"
|
||||
android:label="@string/widget_atlas_name"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_atlas" />
|
||||
</receiver>
|
||||
<receiver
|
||||
android:name=".widget.CollabWidgetReceiver"
|
||||
android:label="@string/widget_collab_name"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_collab" />
|
||||
</receiver>
|
||||
<!-- Push: the relay's data messages, decrypted and shown here. -->
|
||||
<service
|
||||
android:name=".TrekPushService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
@@ -213,12 +76,5 @@
|
||||
Play hide the app from tablets and other GPS-less devices. -->
|
||||
<uses-feature android:name="android.hardware.location.gps" android:required="false" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<!-- Android 13 and later ask before an app may show notifications. -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<!-- Android 16: the travel day's Live Update may be promoted to the top of the shade. -->
|
||||
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<!-- Tablets, Chromebooks and TVs without these still get the app on Play. -->
|
||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.screen.portrait" android:required="false" />
|
||||
</manifest>
|
||||
|
||||
@@ -2,25 +2,14 @@ package com.liketrek.trek;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.KeyboardShortcutGroup;
|
||||
import android.view.Menu;
|
||||
import android.view.View;
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
import com.liketrek.trek.screen.TrekKeyCommands;
|
||||
import com.liketrek.trek.tiles.TrekTileStore;
|
||||
import com.liketrek.trek.widget.TrekWidgetUpdater;
|
||||
import java.util.List;
|
||||
|
||||
public class MainActivity extends BridgeActivity {
|
||||
|
||||
/** The share sheet (share/ShareActivity.kt) left an item for the import. */
|
||||
public static final String ACTION_SHARE_HANDOFF = "com.liketrek.trek.SHARE_HANDOFF";
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
registerPlugin(TrekShellPlugin.class);
|
||||
TrekTileStore.INSTANCE.init(this);
|
||||
// With a server picked, the WebView loads that server instead of the
|
||||
// bundled address screen, so the UI arrives in the version the server
|
||||
// runs and on its own origin. BridgeActivity reads `config` while it
|
||||
@@ -37,33 +26,6 @@ public class MainActivity extends BridgeActivity {
|
||||
bridge.getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER);
|
||||
// Set before any WebView callback can run: they are posted to this thread.
|
||||
if (onServer) bridge.setWebViewClient(new TrekWebViewClient(bridge, this::showAddressScreen));
|
||||
TrekSharedFiles.receive(this, getIntent());
|
||||
// Android 15's widget picker draws the widgets live from a sample trip.
|
||||
TrekWidgetUpdater.publishPreviews(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* A hardware keyboard's shortcut runs the UI's command (TrekKeyCommands),
|
||||
* wherever the focus is; everything else goes on to the WebView as usual.
|
||||
*/
|
||||
@Override
|
||||
public boolean dispatchKeyEvent(KeyEvent event) {
|
||||
if (TrekKeyCommands.INSTANCE.dispatch(event)) return true;
|
||||
return super.dispatchKeyEvent(event);
|
||||
}
|
||||
|
||||
/** The UI's shortcuts in the system's list (Meta + /), under their names. */
|
||||
@Override
|
||||
public void onProvideKeyboardShortcuts(List<KeyboardShortcutGroup> data, Menu menu, int deviceId) {
|
||||
super.onProvideKeyboardShortcuts(data, menu, deviceId);
|
||||
KeyboardShortcutGroup group = TrekKeyCommands.INSTANCE.shortcutGroup(getString(R.string.app_name));
|
||||
if (group != null) data.add(group);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
TrekSharedFiles.receive(this, intent);
|
||||
}
|
||||
|
||||
private boolean leavingServer;
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
package com.liketrek.trek;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.security.keystore.KeyGenParameterSpec;
|
||||
import android.security.keystore.KeyProperties;
|
||||
import android.util.Base64;
|
||||
import java.math.BigInteger;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.KeyStore;
|
||||
import java.security.interfaces.ECPrivateKey;
|
||||
import java.security.interfaces.ECPublicKey;
|
||||
import java.security.spec.ECGenParameterSpec;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.security.SecureRandom;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.KeyGenerator;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
|
||||
/**
|
||||
* The device's Web Push key pair (P-256) and auth secret (RFC 8291). The server
|
||||
* encrypts every notification for these, and TrekPushService decrypts with
|
||||
* them. They are kept in the app's preferences, sealed with an AES key that
|
||||
* never leaves the Android Keystore, so a copy of the preferences alone opens
|
||||
* nothing.
|
||||
*/
|
||||
final class TrekPushKeys {
|
||||
|
||||
private static final String PREFS = "trek_push";
|
||||
private static final String WRAP_ALIAS = "trek_push_wrap";
|
||||
private static final String PRIVATE = "private";
|
||||
private static final String PUBLIC = "public";
|
||||
private static final String AUTH = "auth";
|
||||
|
||||
final ECPrivateKey privateKey;
|
||||
/** Uncompressed point, 65 bytes, as the server reads p256dh. */
|
||||
final byte[] publicKey;
|
||||
final byte[] auth;
|
||||
|
||||
TrekPushKeys(ECPrivateKey privateKey, byte[] publicKey, byte[] auth) {
|
||||
this.privateKey = privateKey;
|
||||
this.publicKey = publicKey;
|
||||
this.auth = auth;
|
||||
}
|
||||
|
||||
/** The stored keys, or null when the app never subscribed. */
|
||||
static synchronized TrekPushKeys load(Context context) throws Exception {
|
||||
SharedPreferences prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||
String sealedPrivate = prefs.getString(PRIVATE, null);
|
||||
String publicKey = prefs.getString(PUBLIC, null);
|
||||
String sealedAuth = prefs.getString(AUTH, null);
|
||||
if (sealedPrivate == null || publicKey == null || sealedAuth == null) return null;
|
||||
byte[] pkcs8 = unseal(sealedPrivate);
|
||||
ECPrivateKey key = (ECPrivateKey) KeyFactory.getInstance("EC").generatePrivate(new PKCS8EncodedKeySpec(pkcs8));
|
||||
return new TrekPushKeys(key, Base64.decode(publicKey, Base64.NO_WRAP), unseal(sealedAuth));
|
||||
}
|
||||
|
||||
static synchronized TrekPushKeys loadOrCreate(Context context) throws Exception {
|
||||
TrekPushKeys existing;
|
||||
try {
|
||||
existing = load(context);
|
||||
} catch (Exception unreadable) {
|
||||
// The Keystore key is gone (backup restored to another device, data
|
||||
// cleared): start over, the next subscription carries the new keys.
|
||||
existing = null;
|
||||
}
|
||||
if (existing != null) return existing;
|
||||
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
|
||||
generator.initialize(new ECGenParameterSpec("secp256r1"));
|
||||
KeyPair pair = generator.generateKeyPair();
|
||||
byte[] publicKey = uncompressed((ECPublicKey) pair.getPublic());
|
||||
byte[] auth = new byte[16];
|
||||
new SecureRandom().nextBytes(auth);
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(PRIVATE, seal(pair.getPrivate().getEncoded()))
|
||||
.putString(PUBLIC, Base64.encodeToString(publicKey, Base64.NO_WRAP))
|
||||
.putString(AUTH, seal(auth))
|
||||
.apply();
|
||||
return new TrekPushKeys((ECPrivateKey) pair.getPrivate(), publicKey, auth);
|
||||
}
|
||||
|
||||
static String base64url(byte[] data) {
|
||||
return Base64.encodeToString(data, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING);
|
||||
}
|
||||
|
||||
static byte[] uncompressed(ECPublicKey key) {
|
||||
byte[] out = new byte[65];
|
||||
out[0] = 0x04;
|
||||
copyFixed(key.getW().getAffineX(), out, 1);
|
||||
copyFixed(key.getW().getAffineY(), out, 33);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A coordinate as exactly 32 bytes: BigInteger adds a sign byte or drops leading zeros. */
|
||||
private static void copyFixed(BigInteger value, byte[] out, int offset) {
|
||||
byte[] raw = value.toByteArray();
|
||||
int start = Math.max(0, raw.length - 32);
|
||||
int length = raw.length - start;
|
||||
System.arraycopy(raw, start, out, offset + 32 - length, length);
|
||||
}
|
||||
|
||||
private static SecretKey wrapKey() throws Exception {
|
||||
KeyStore store = KeyStore.getInstance("AndroidKeyStore");
|
||||
store.load(null);
|
||||
if (store.containsAlias(WRAP_ALIAS)) return ((KeyStore.SecretKeyEntry) store.getEntry(WRAP_ALIAS, null)).getSecretKey();
|
||||
KeyGenerator generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore");
|
||||
generator.init(
|
||||
new KeyGenParameterSpec.Builder(WRAP_ALIAS, KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setKeySize(256)
|
||||
.build()
|
||||
);
|
||||
return generator.generateKey();
|
||||
}
|
||||
|
||||
private static String seal(byte[] plain) throws Exception {
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, wrapKey());
|
||||
byte[] iv = cipher.getIV();
|
||||
byte[] sealed = cipher.doFinal(plain);
|
||||
byte[] out = new byte[iv.length + sealed.length];
|
||||
System.arraycopy(iv, 0, out, 0, iv.length);
|
||||
System.arraycopy(sealed, 0, out, iv.length, sealed.length);
|
||||
return Base64.encodeToString(out, Base64.NO_WRAP);
|
||||
}
|
||||
|
||||
private static byte[] unseal(String value) throws Exception {
|
||||
byte[] in = Base64.decode(value, Base64.NO_WRAP);
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, wrapKey(), new GCMParameterSpec(128, in, 0, 12));
|
||||
return cipher.doFinal(in, 12, in.length - 12);
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
package com.liketrek.trek;
|
||||
|
||||
import android.Manifest;
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
import android.util.Base64;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import com.google.firebase.messaging.FirebaseMessagingService;
|
||||
import com.google.firebase.messaging.RemoteMessage;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import org.json.JSONObject;
|
||||
|
||||
/**
|
||||
* Receives the relay's data messages ({enc}: the server's encrypted Web Push
|
||||
* record), decrypts them with the device's keys and shows the notification.
|
||||
* Without the keys, or when the record does not open, it shows the same
|
||||
* neutral text the iOS extension falls back to, never the ciphertext.
|
||||
*/
|
||||
public class TrekPushService extends FirebaseMessagingService {
|
||||
|
||||
static final String CHANNEL = "trek";
|
||||
static final String EXTRA_URL = "trek.pushUrl";
|
||||
|
||||
@Override
|
||||
public void onMessageReceived(@NonNull RemoteMessage message) {
|
||||
String enc = message.getData().get("enc");
|
||||
if (enc == null) return;
|
||||
String title = getString(R.string.push_fallback_title);
|
||||
String body = getString(R.string.push_fallback_body);
|
||||
String url = null;
|
||||
String tag = null;
|
||||
String event = null;
|
||||
try {
|
||||
TrekPushKeys keys = TrekPushKeys.load(this);
|
||||
if (keys != null) {
|
||||
byte[] plain = TrekWebPush.decrypt(Base64.decode(enc, Base64.DEFAULT), keys);
|
||||
JSONObject payload = new JSONObject(new String(plain, StandardCharsets.UTF_8));
|
||||
if (!payload.optString("title").isEmpty()) title = payload.optString("title");
|
||||
body = payload.optString("body", body);
|
||||
url = payload.optString("url", null);
|
||||
tag = payload.optString("tag", null);
|
||||
event = payload.optString("event", null);
|
||||
}
|
||||
} catch (Exception unreadable) {
|
||||
// Shown with the neutral text: the tap still opens the app.
|
||||
}
|
||||
show(this, title, body, url, tag, event);
|
||||
}
|
||||
|
||||
/** A new token means a new endpoint; the UI re-registers on its next start (resyncPushSubscription). */
|
||||
@Override
|
||||
public void onNewToken(@NonNull String token) {}
|
||||
|
||||
/** The channels, so each kind can be silenced, raised or kept on its own in the system settings. */
|
||||
static final String CHANNEL_CHAT = "chat";
|
||||
static final String CHANNEL_TRIPS = "trips";
|
||||
static final String CHANNEL_INVITES = "invites";
|
||||
|
||||
/**
|
||||
* The channel of a notification by the event the server names (PushPayload.event,
|
||||
* notification-events.ts). A server too old to send one lands in the general channel.
|
||||
*/
|
||||
static String channelFor(String event) {
|
||||
if (event == null) return CHANNEL;
|
||||
switch (event) {
|
||||
case "collab_message":
|
||||
return CHANNEL_CHAT;
|
||||
case "booking_change":
|
||||
case "trip_reminder":
|
||||
case "todo_due":
|
||||
case "packing_tagged":
|
||||
return CHANNEL_TRIPS;
|
||||
case "trip_invite":
|
||||
case "vacay_invite":
|
||||
case "vacay_share":
|
||||
case "collection_invite":
|
||||
case "photos_shared":
|
||||
return CHANNEL_INVITES;
|
||||
default:
|
||||
return CHANNEL;
|
||||
}
|
||||
}
|
||||
|
||||
static void ensureChannel(Context context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
|
||||
NotificationManager manager = context.getSystemService(NotificationManager.class);
|
||||
if (manager == null) return;
|
||||
create(manager, CHANNEL_CHAT, context.getString(R.string.push_channel_chat), NotificationManager.IMPORTANCE_HIGH);
|
||||
create(manager, CHANNEL_TRIPS, context.getString(R.string.push_channel_trips), NotificationManager.IMPORTANCE_DEFAULT);
|
||||
create(manager, CHANNEL_INVITES, context.getString(R.string.push_channel_invites), NotificationManager.IMPORTANCE_DEFAULT);
|
||||
create(manager, CHANNEL, context.getString(R.string.push_channel_name), NotificationManager.IMPORTANCE_DEFAULT);
|
||||
com.liketrek.trek.tripday.TripDayNotification.INSTANCE.ensureChannel(context);
|
||||
}
|
||||
|
||||
@androidx.annotation.RequiresApi(Build.VERSION_CODES.O)
|
||||
private static void create(NotificationManager manager, String id, String name, int importance) {
|
||||
NotificationChannel existing = manager.getNotificationChannel(id);
|
||||
if (existing != null) {
|
||||
// Only the name follows the app's language; what the user set stays theirs.
|
||||
if (!name.contentEquals(existing.getName())) {
|
||||
existing.setName(name);
|
||||
manager.createNotificationChannel(existing);
|
||||
}
|
||||
return;
|
||||
}
|
||||
manager.createNotificationChannel(new NotificationChannel(id, name, importance));
|
||||
}
|
||||
|
||||
private static void show(Context context, String title, String body, String url, String tag, String event) {
|
||||
ensureChannel(context);
|
||||
Intent open = new Intent(context, MainActivity.class)
|
||||
.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||
if (url != null) open.putExtra(EXTRA_URL, url);
|
||||
int id = tag != null ? tag.hashCode() : (int) (System.currentTimeMillis() & 0x7fffffff);
|
||||
PendingIntent tap = PendingIntent.getActivity(
|
||||
context,
|
||||
id,
|
||||
open,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
|
||||
);
|
||||
String channel = channelFor(event);
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channel)
|
||||
.setSmallIcon(R.drawable.ic_stat_trek)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setStyle(new NotificationCompat.BigTextStyle().bigText(body))
|
||||
.setAutoCancel(true)
|
||||
.setColor(ContextCompat.getColor(context, R.color.notification_accent))
|
||||
.setCategory(CHANNEL_CHAT.equals(channel) ? NotificationCompat.CATEGORY_MESSAGE : NotificationCompat.CATEGORY_EVENT)
|
||||
// One stack per kind in the shade, rather than a loose pile.
|
||||
.setGroup("trek." + channel)
|
||||
.setContentIntent(tap);
|
||||
if (Build.VERSION.SDK_INT >= 33
|
||||
&& ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
NotificationManagerCompat.from(context).notify(tag, id, builder.build());
|
||||
// The stack's head: the system folds the group under it from two notifications on.
|
||||
NotificationCompat.Builder summary = new NotificationCompat.Builder(context, channel)
|
||||
.setSmallIcon(R.drawable.ic_stat_trek)
|
||||
.setColor(ContextCompat.getColor(context, R.color.notification_accent))
|
||||
.setGroup("trek." + channel)
|
||||
.setGroupSummary(true)
|
||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
||||
.setAutoCancel(true);
|
||||
NotificationManagerCompat.from(context).notify("trek.summary", channel.hashCode(), summary.build());
|
||||
} catch (SecurityException notAllowed) {
|
||||
// Notifications switched off for the app since it subscribed.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package com.liketrek.trek;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.provider.OpenableColumns;
|
||||
import android.util.Base64;
|
||||
import com.getcapacitor.JSObject;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
|
||||
/**
|
||||
* Booking documents shared to TREK (open with / share), held until the UI takes
|
||||
* them (TrekShell.takeSharedFile) and announced if it is already running.
|
||||
*/
|
||||
final class TrekSharedFiles {
|
||||
/** Above this the file is not a booking confirmation. */
|
||||
private static final int MAX_BYTES = 15 * 1024 * 1024;
|
||||
|
||||
interface Listener {
|
||||
void onFile(JSObject file);
|
||||
}
|
||||
|
||||
private static JSObject pending;
|
||||
private static Listener listener;
|
||||
|
||||
private TrekSharedFiles() {}
|
||||
|
||||
static void setListener(Listener l) {
|
||||
listener = l;
|
||||
}
|
||||
|
||||
static JSObject take() {
|
||||
JSObject file = pending;
|
||||
pending = null;
|
||||
return file;
|
||||
}
|
||||
|
||||
static void receive(Context context, Intent intent) {
|
||||
if (intent == null) return;
|
||||
if (MainActivity.ACTION_SHARE_HANDOFF.equals(intent.getAction())) {
|
||||
receiveHandoff(context);
|
||||
return;
|
||||
}
|
||||
Uri uri = null;
|
||||
// A view intent also brings sign-in callbacks and widget links; only a file is a document.
|
||||
if (Intent.ACTION_VIEW.equals(intent.getAction()) && intent.getData() != null
|
||||
&& "content".equals(intent.getData().getScheme())) {
|
||||
uri = intent.getData();
|
||||
}
|
||||
else if (Intent.ACTION_SEND.equals(intent.getAction())) uri = intent.getParcelableExtra(Intent.EXTRA_STREAM);
|
||||
// content:// only: a file:// path would be read with TREK's own rights.
|
||||
if (uri == null || !"content".equals(uri.getScheme())) return;
|
||||
try (InputStream in = context.getContentResolver().openInputStream(uri)) {
|
||||
if (in == null) return;
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
byte[] buffer = new byte[16384];
|
||||
int read;
|
||||
while ((read = in.read(buffer)) != -1) {
|
||||
out.write(buffer, 0, read);
|
||||
if (out.size() > MAX_BYTES) return;
|
||||
}
|
||||
JSObject file = new JSObject();
|
||||
file.put("name", displayName(context, uri));
|
||||
String type = context.getContentResolver().getType(uri);
|
||||
file.put("mimeType", type == null ? intent.getType() : type);
|
||||
file.put("data", Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP));
|
||||
pending = file;
|
||||
if (listener != null) listener.onFile(file);
|
||||
} catch (Exception ignored) {
|
||||
// Unreadable or gone: nothing to import.
|
||||
}
|
||||
}
|
||||
|
||||
/** The item the share sheet left (share/ShareHandoff.kt), with the trip picked for it there. */
|
||||
static void receiveHandoff(Context context) {
|
||||
kotlin.Pair<org.json.JSONObject, byte[]> item = com.liketrek.trek.share.ShareHandoff.INSTANCE.take(context);
|
||||
if (item == null || item.getSecond().length > MAX_BYTES) return;
|
||||
org.json.JSONObject meta = item.getFirst();
|
||||
JSObject file = new JSObject();
|
||||
file.put("name", meta.optString("name", "booking"));
|
||||
file.put("mimeType", meta.optString("mimeType", "application/octet-stream"));
|
||||
file.put("data", Base64.encodeToString(item.getSecond(), Base64.NO_WRAP));
|
||||
if (meta.has("tripId")) file.put("tripId", meta.optLong("tripId"));
|
||||
pending = file;
|
||||
if (listener != null) listener.onFile(file);
|
||||
}
|
||||
|
||||
private static String displayName(Context context, Uri uri) {
|
||||
try (Cursor cursor = context.getContentResolver().query(uri, new String[] { OpenableColumns.DISPLAY_NAME }, null, null, null)) {
|
||||
if (cursor != null && cursor.moveToFirst()) return cursor.getString(0);
|
||||
} catch (Exception ignored) {
|
||||
// Fall through to the path.
|
||||
}
|
||||
String last = uri.getLastPathSegment();
|
||||
return last == null ? "booking" : last;
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,19 @@
|
||||
package com.liketrek.trek;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import androidx.browser.customtabs.CustomTabsIntent;
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.PermissionState;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
import com.getcapacitor.annotation.Permission;
|
||||
import com.getcapacitor.annotation.PermissionCallback;
|
||||
import com.google.firebase.messaging.FirebaseMessaging;
|
||||
import org.json.JSONObject;
|
||||
|
||||
/** The native half of client/src/native/trekShell.ts. */
|
||||
@CapacitorPlugin(
|
||||
name = "TrekShell",
|
||||
permissions = {
|
||||
@Permission(strings = { Manifest.permission.POST_NOTIFICATIONS }, alias = "notifications"),
|
||||
@Permission(strings = { Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION }, alias = "location"),
|
||||
}
|
||||
)
|
||||
@CapacitorPlugin(name = "TrekShell")
|
||||
public class TrekShellPlugin extends Plugin {
|
||||
|
||||
/** How long to wait after returning from the browser for the redirect to land. */
|
||||
@@ -37,225 +24,6 @@ public class TrekShellPlugin extends Plugin {
|
||||
private boolean authBrowserOpen;
|
||||
private final Handler handler = new Handler(Looper.getMainLooper());
|
||||
|
||||
/**
|
||||
* What this binary offers the server's UI, raised with every native feature
|
||||
* an older binary lacks. The UI asks before it calls (native/capabilities.ts),
|
||||
* and a server can require a minimum (minNativeShellApi in /api/health/features).
|
||||
*/
|
||||
static final int SHELL_API = 2;
|
||||
/** The native features by name, so the UI can turn each on by itself. */
|
||||
static final String[] FEATURES = {
|
||||
"capabilities",
|
||||
"biometricLock",
|
||||
"sharedFiles",
|
||||
"nativePush",
|
||||
"geolocation",
|
||||
"tileCache",
|
||||
"widgets",
|
||||
"widgetTrips",
|
||||
"liveActivity",
|
||||
"shareSheet",
|
||||
"playBilling",
|
||||
"googleWallet",
|
||||
"keyCommands",
|
||||
"windowLayout",
|
||||
};
|
||||
|
||||
/** liketrek's push relay: the endpoint for this device's FCM token (trek-hosted push-relay.ts). */
|
||||
static final String PUSH_RELAY = "https://push.liketrek.com/v1/fcm/";
|
||||
|
||||
/** The notification the app was started from, until the UI asks for it. */
|
||||
private String openedPushUrl;
|
||||
|
||||
@PluginMethod
|
||||
public void getCapabilities(PluginCall call) {
|
||||
JSObject result = new JSObject();
|
||||
result.put("shellApi", SHELL_API);
|
||||
result.put("platform", "android");
|
||||
String version = "";
|
||||
String build = "";
|
||||
try {
|
||||
android.content.pm.PackageInfo info = getContext().getPackageManager().getPackageInfo(getContext().getPackageName(), 0);
|
||||
version = info.versionName == null ? "" : info.versionName;
|
||||
build = String.valueOf(androidx.core.content.pm.PackageInfoCompat.getLongVersionCode(info));
|
||||
} catch (android.content.pm.PackageManager.NameNotFoundException ignored) {
|
||||
// Our own package is always there; the fields simply stay empty.
|
||||
}
|
||||
result.put("appVersion", version);
|
||||
result.put("build", build);
|
||||
com.getcapacitor.JSArray features = new com.getcapacitor.JSArray();
|
||||
for (String feature : FEATURES) features.put(feature);
|
||||
result.put("features", features);
|
||||
call.resolve(result);
|
||||
}
|
||||
|
||||
// ---- Shared booking documents ----
|
||||
|
||||
@Override
|
||||
public void load() {
|
||||
TrekSharedFiles.setListener(file -> notifyListeners("sharedFile", file));
|
||||
// A reloaded page starts without a stream; whatever the old one ran is stopped.
|
||||
if (location != null) location.stop();
|
||||
locationActive = false;
|
||||
// A pending purchase that cleared while the shell runs: redeemed like a restore.
|
||||
com.liketrek.trek.billing.TrekBilling.INSTANCE.setOnTransaction(receipt -> {
|
||||
notifyListeners("storeTransaction", receipt);
|
||||
return kotlin.Unit.INSTANCE;
|
||||
});
|
||||
com.liketrek.trek.screen.TrekKeyCommands.INSTANCE.setOnPress(id -> {
|
||||
JSObject pressed = new JSObject();
|
||||
pressed.put("id", id);
|
||||
notifyListeners("keyCommand", pressed);
|
||||
return kotlin.Unit.INSTANCE;
|
||||
});
|
||||
if (getActivity() != null) {
|
||||
com.liketrek.trek.screen.TrekWindowLayout.INSTANCE.observe(getActivity(), layout -> {
|
||||
notifyListeners("windowLayout", layout);
|
||||
return kotlin.Unit.INSTANCE;
|
||||
});
|
||||
}
|
||||
if (getActivity() != null && getActivity().getIntent() != null) {
|
||||
openedPushUrl = getActivity().getIntent().getStringExtra(TrekPushService.EXTRA_URL);
|
||||
getActivity().getIntent().removeExtra(TrekPushService.EXTRA_URL);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Push ----
|
||||
|
||||
/**
|
||||
* Permission, the FCM token and the device keys, as the Web Push subscription
|
||||
* the server stores (native/push.ts). Rejects with DENIED when the user
|
||||
* refuses notifications, as on iOS.
|
||||
*/
|
||||
@PluginMethod
|
||||
public void registerPush(PluginCall call) {
|
||||
if (Build.VERSION.SDK_INT >= 33 && getPermissionState("notifications") != PermissionState.GRANTED) {
|
||||
requestPermissionForAlias("notifications", call, "pushPermissionAnswered");
|
||||
return;
|
||||
}
|
||||
subscribePush(call);
|
||||
}
|
||||
|
||||
@PermissionCallback
|
||||
private void pushPermissionAnswered(PluginCall call) {
|
||||
if (getPermissionState("notifications") != PermissionState.GRANTED) {
|
||||
call.reject("Notifications are not allowed", "DENIED");
|
||||
return;
|
||||
}
|
||||
subscribePush(call);
|
||||
}
|
||||
|
||||
private void subscribePush(PluginCall call) {
|
||||
if (!NotificationManagerCompat.from(getContext()).areNotificationsEnabled()) {
|
||||
call.reject("Notifications are not allowed", "DENIED");
|
||||
return;
|
||||
}
|
||||
TrekPushService.ensureChannel(getContext());
|
||||
final TrekPushKeys keys;
|
||||
try {
|
||||
keys = TrekPushKeys.loadOrCreate(getContext());
|
||||
} catch (Exception e) {
|
||||
call.reject("Could not create the push keys", e);
|
||||
return;
|
||||
}
|
||||
FirebaseMessaging.getInstance().getToken().addOnCompleteListener(task -> {
|
||||
if (!task.isSuccessful() || task.getResult() == null) {
|
||||
call.reject("Could not get a push token", task.getException());
|
||||
return;
|
||||
}
|
||||
JSObject subscription = new JSObject();
|
||||
subscription.put("endpoint", PUSH_RELAY + task.getResult());
|
||||
JSObject keyObject = new JSObject();
|
||||
keyObject.put("p256dh", TrekPushKeys.base64url(keys.publicKey));
|
||||
keyObject.put("auth", TrekPushKeys.base64url(keys.auth));
|
||||
subscription.put("keys", keyObject);
|
||||
call.resolve(subscription);
|
||||
});
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void takeOpenedPush(PluginCall call) {
|
||||
JSObject result = new JSObject();
|
||||
result.put("url", openedPushUrl == null ? JSONObject.NULL : openedPushUrl);
|
||||
openedPushUrl = null;
|
||||
call.resolve(result);
|
||||
}
|
||||
|
||||
/** The document shared to TREK before the UI was there to hear of it. */
|
||||
@PluginMethod
|
||||
public void takeSharedFile(PluginCall call) {
|
||||
JSObject result = new JSObject();
|
||||
JSObject file = TrekSharedFiles.take();
|
||||
result.put("file", file == null ? JSONObject.NULL : file);
|
||||
call.resolve(result);
|
||||
}
|
||||
|
||||
// ---- App lock ----
|
||||
|
||||
private static final int AUTHENTICATORS =
|
||||
androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK
|
||||
| androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL;
|
||||
|
||||
/** Which biometry the device offers for the app lock, if any. */
|
||||
@PluginMethod
|
||||
public void biometryInfo(PluginCall call) {
|
||||
int status = androidx.biometric.BiometricManager.from(getContext()).canAuthenticate(AUTHENTICATORS);
|
||||
JSObject result = new JSObject();
|
||||
result.put("available", status == androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS);
|
||||
result.put("type", "biometric");
|
||||
call.resolve(result);
|
||||
}
|
||||
|
||||
/** The system biometric prompt with the screen lock as the way out. */
|
||||
@PluginMethod
|
||||
public void authenticateBiometric(PluginCall call) {
|
||||
String reason = call.getString("reason", "Unlock TREK");
|
||||
getActivity().runOnUiThread(() -> {
|
||||
androidx.fragment.app.FragmentActivity activity = (androidx.fragment.app.FragmentActivity) getActivity();
|
||||
androidx.biometric.BiometricPrompt prompt = new androidx.biometric.BiometricPrompt(
|
||||
activity,
|
||||
androidx.core.content.ContextCompat.getMainExecutor(getContext()),
|
||||
new androidx.biometric.BiometricPrompt.AuthenticationCallback() {
|
||||
@Override
|
||||
public void onAuthenticationSucceeded(androidx.biometric.BiometricPrompt.AuthenticationResult result) {
|
||||
JSObject ok = new JSObject();
|
||||
ok.put("ok", true);
|
||||
call.resolve(ok);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAuthenticationError(int code, CharSequence message) {
|
||||
boolean cancelled = code == androidx.biometric.BiometricPrompt.ERROR_USER_CANCELED
|
||||
|| code == androidx.biometric.BiometricPrompt.ERROR_NEGATIVE_BUTTON
|
||||
|| code == androidx.biometric.BiometricPrompt.ERROR_CANCELED;
|
||||
call.reject(message.toString(), cancelled ? "CANCELLED" : "FAILED");
|
||||
}
|
||||
}
|
||||
);
|
||||
androidx.biometric.BiometricPrompt.PromptInfo info = new androidx.biometric.BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(reason)
|
||||
.setAllowedAuthenticators(AUTHENTICATORS)
|
||||
.build();
|
||||
prompt.authenticate(info);
|
||||
});
|
||||
}
|
||||
|
||||
/** While on, the recent-apps screen shows no snapshot of the app. */
|
||||
@PluginMethod
|
||||
public void setPrivacyCover(PluginCall call) {
|
||||
boolean enabled = Boolean.TRUE.equals(call.getBoolean("enabled", false));
|
||||
getActivity().runOnUiThread(() -> {
|
||||
if (android.os.Build.VERSION.SDK_INT >= 33) {
|
||||
getActivity().setRecentsScreenshotEnabled(!enabled);
|
||||
} else if (enabled) {
|
||||
getActivity().getWindow().addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE);
|
||||
} else {
|
||||
getActivity().getWindow().clearFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE);
|
||||
}
|
||||
});
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void getServer(PluginCall call) {
|
||||
String url = TrekServerStore.getUrl(getContext());
|
||||
@@ -292,8 +60,6 @@ public class TrekShellPlugin extends Plugin {
|
||||
|
||||
/** The server URL is part of the bridge's configuration, so it takes a new activity. */
|
||||
private void restart() {
|
||||
locationActive = false;
|
||||
if (location != null) location.stop();
|
||||
TrekServerStore.markRestart(getContext());
|
||||
getActivity().runOnUiThread(() -> getActivity().recreate());
|
||||
}
|
||||
@@ -321,14 +87,6 @@ public class TrekShellPlugin extends Plugin {
|
||||
@Override
|
||||
protected void handleOnNewIntent(Intent intent) {
|
||||
super.handleOnNewIntent(intent);
|
||||
String pushUrl = intent.getStringExtra(TrekPushService.EXTRA_URL);
|
||||
if (pushUrl != null) {
|
||||
intent.removeExtra(TrekPushService.EXTRA_URL);
|
||||
JSObject opened = new JSObject();
|
||||
opened.put("url", pushUrl);
|
||||
notifyListeners("pushOpened", opened);
|
||||
return;
|
||||
}
|
||||
Uri data = intent.getData();
|
||||
PluginCall call = pendingAuthCall();
|
||||
if (call == null || data == null || !authScheme.equals(data.getScheme())) return;
|
||||
@@ -346,7 +104,6 @@ public class TrekShellPlugin extends Plugin {
|
||||
@Override
|
||||
protected void handleOnResume() {
|
||||
super.handleOnResume();
|
||||
if (locationActive && location != null && locationAllowed()) location.start(locationHighAccuracy);
|
||||
if (!authBrowserOpen) return;
|
||||
authBrowserOpen = false;
|
||||
handler.postDelayed(() -> {
|
||||
@@ -389,245 +146,4 @@ public class TrekShellPlugin extends Plugin {
|
||||
result.put("opened", false);
|
||||
call.resolve(result);
|
||||
}
|
||||
|
||||
// ---- Widgets, shortcuts and the travel day ----
|
||||
|
||||
/**
|
||||
* What the home-screen widgets show (native/widgets.ts), as JSON: "snapshot"
|
||||
* (the dashboard's), "trip.<id>" or "trips" (the list a widget and the share
|
||||
* sheet pick from). The widgets redraw, and a new trip list rebuilds the
|
||||
* trip shortcuts.
|
||||
*/
|
||||
@PluginMethod
|
||||
public void setWidgetSnapshot(PluginCall call) {
|
||||
String json = call.getString("json");
|
||||
String key = call.getString("key", "snapshot");
|
||||
if (json == null) {
|
||||
call.reject("json is required");
|
||||
return;
|
||||
}
|
||||
if (!com.liketrek.trek.widget.WidgetStore.INSTANCE.isValidKey(key)) {
|
||||
call.reject("key must be snapshot, trips or trip.<id>");
|
||||
return;
|
||||
}
|
||||
android.content.Context context = getContext().getApplicationContext();
|
||||
try {
|
||||
com.liketrek.trek.widget.WidgetStore.INSTANCE.write(context, key, json);
|
||||
} catch (Exception e) {
|
||||
call.reject("Could not store the widget snapshot", e);
|
||||
return;
|
||||
}
|
||||
if ("trips".equals(key)) {
|
||||
new Thread(() -> com.liketrek.trek.share.TrekShortcuts.INSTANCE.publish(context)).start();
|
||||
} else {
|
||||
com.liketrek.trek.widget.TrekWidgetUpdater.refreshAll(context);
|
||||
}
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
/** Starts the travel day's Live Update, or updates the one showing. */
|
||||
@PluginMethod
|
||||
public void syncTripDay(PluginCall call) {
|
||||
String title = call.getString("tripTitle");
|
||||
String path = call.getString("path");
|
||||
String dayLabel = call.getString("dayLabel");
|
||||
String nextName = call.getString("nextName");
|
||||
if (title == null || path == null || dayLabel == null || nextName == null) {
|
||||
call.reject("tripTitle, path, dayLabel and nextName are required");
|
||||
return;
|
||||
}
|
||||
java.util.List<String> colors = new java.util.ArrayList<>();
|
||||
com.getcapacitor.JSArray raw = call.getArray("colors");
|
||||
if (raw != null) {
|
||||
for (int i = 0; i < raw.length(); i++) colors.add(raw.isNull(i) ? null : raw.optString(i, null));
|
||||
}
|
||||
com.liketrek.trek.tripday.TripDayNotification.State state = new com.liketrek.trek.tripday.TripDayNotification.State(
|
||||
title,
|
||||
path,
|
||||
dayLabel,
|
||||
nextName,
|
||||
call.getString("nextTime"),
|
||||
call.getInt("nextMinutes"),
|
||||
call.getInt("done", 0),
|
||||
call.getInt("total", 0),
|
||||
colors
|
||||
);
|
||||
boolean running = com.liketrek.trek.tripday.TripDayNotification.INSTANCE.show(getContext(), state);
|
||||
JSObject result = new JSObject();
|
||||
result.put("running", running);
|
||||
call.resolve(result);
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void endTripDay(PluginCall call) {
|
||||
com.liketrek.trek.tripday.TripDayNotification.INSTANCE.end(getContext());
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
// ---- Location ----
|
||||
|
||||
private com.liketrek.trek.location.TrekLocation location;
|
||||
private boolean locationHighAccuracy;
|
||||
|
||||
private com.liketrek.trek.location.TrekLocation location() {
|
||||
if (location == null) {
|
||||
location = new com.liketrek.trek.location.TrekLocation(getContext(), payload -> {
|
||||
notifyListeners("location", payload);
|
||||
return kotlin.Unit.INSTANCE;
|
||||
});
|
||||
}
|
||||
return location;
|
||||
}
|
||||
|
||||
/** Whether the app may know where the device is: precisely, or "Approximate" only. */
|
||||
private boolean locationAllowed() {
|
||||
return androidx.core.content.ContextCompat.checkSelfPermission(getContext(), Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
== android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
|| androidx.core.content.ContextCompat.checkSelfPermission(getContext(), Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||
== android.content.pm.PackageManager.PERMISSION_GRANTED;
|
||||
}
|
||||
|
||||
/** True while the UI has a stream running, so it resumes after the app was in the background. */
|
||||
private boolean locationActive;
|
||||
|
||||
/**
|
||||
* One location stream for the UI's navigator.geolocation, asking for the
|
||||
* permission the first time. "Approximate" counts as allowed; the stream then
|
||||
* runs on the coarse fixes Android gives.
|
||||
*/
|
||||
@PluginMethod
|
||||
public void startLocation(PluginCall call) {
|
||||
locationHighAccuracy = Boolean.TRUE.equals(call.getBoolean("highAccuracy", false));
|
||||
if (!locationAllowed()) {
|
||||
requestPermissionForAlias("location", call, "locationPermissionAnswered");
|
||||
return;
|
||||
}
|
||||
locationActive = true;
|
||||
location().start(locationHighAccuracy);
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
@PermissionCallback
|
||||
private void locationPermissionAnswered(PluginCall call) {
|
||||
if (!locationAllowed()) {
|
||||
location().emitError(1, "Location permission denied");
|
||||
} else {
|
||||
locationActive = true;
|
||||
location().start(locationHighAccuracy);
|
||||
}
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void stopLocation(PluginCall call) {
|
||||
locationActive = false;
|
||||
if (location != null) location.stop();
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
/** No location in the background, as in a browser: paused with the app, resumed with it. */
|
||||
@Override
|
||||
protected void handleOnPause() {
|
||||
super.handleOnPause();
|
||||
if (location != null) location.stop();
|
||||
}
|
||||
|
||||
/** The page and the activity are gone: nothing can stop the stream any more but this. */
|
||||
@Override
|
||||
protected void handleOnDestroy() {
|
||||
locationActive = false;
|
||||
if (location != null) location.stop();
|
||||
super.handleOnDestroy();
|
||||
}
|
||||
|
||||
// ---- Map tile store ----
|
||||
|
||||
@PluginMethod
|
||||
public void getTileCacheUsage(PluginCall call) {
|
||||
new Thread(() -> {
|
||||
JSObject result = new JSObject();
|
||||
result.put("bytes", com.liketrek.trek.tiles.TrekTileStore.INSTANCE.usage());
|
||||
result.put("limit", com.liketrek.trek.tiles.TrekTileStore.MAX_BYTES);
|
||||
call.resolve(result);
|
||||
}).start();
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void clearTileCache(PluginCall call) {
|
||||
new Thread(() -> {
|
||||
com.liketrek.trek.tiles.TrekTileStore.INSTANCE.clear();
|
||||
call.resolve();
|
||||
}).start();
|
||||
}
|
||||
|
||||
// ---- Google Play Billing (the StoreKit methods' Android side) ----
|
||||
|
||||
@PluginMethod
|
||||
public void storeProducts(PluginCall call) {
|
||||
com.liketrek.trek.billing.TrekBilling.INSTANCE.products(getContext(), call);
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void storePurchase(PluginCall call) {
|
||||
com.liketrek.trek.billing.TrekBilling.INSTANCE.purchase(getActivity(), call);
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void storeFinish(PluginCall call) {
|
||||
com.liketrek.trek.billing.TrekBilling.INSTANCE.finish(getContext(), call);
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void storeRestore(PluginCall call) {
|
||||
com.liketrek.trek.billing.TrekBilling.INSTANCE.restore(getContext(), call);
|
||||
}
|
||||
|
||||
/** A paid subscription never delivered (it cleared while the app was closed), for the shell to redeem. */
|
||||
@PluginMethod
|
||||
public void storeUnfinished(PluginCall call) {
|
||||
com.liketrek.trek.billing.TrekBilling.INSTANCE.unfinished(getContext(), call);
|
||||
}
|
||||
|
||||
// ---- Google Wallet ----
|
||||
|
||||
/** The only links this hands on: Google Wallet's save-to-wallet page. */
|
||||
static final String GOOGLE_WALLET_SAVE = "https://pay.google.com/gp/v/save/";
|
||||
|
||||
/**
|
||||
* Feature googleWallet: a booking's pass, by the save link the TREK services
|
||||
* signed, into Google Wallet. The Wallet app takes the link where it is
|
||||
* installed, the browser otherwise.
|
||||
*/
|
||||
@PluginMethod
|
||||
public void addToGoogleWallet(PluginCall call) {
|
||||
String url = call.getString("url");
|
||||
if (url == null || !url.startsWith(GOOGLE_WALLET_SAVE)) {
|
||||
call.reject("not a Google Wallet save link", "INVALID");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
getActivity().startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url)).addCategory(Intent.CATEGORY_BROWSABLE));
|
||||
JSObject result = new JSObject();
|
||||
result.put("result", "opened");
|
||||
call.resolve(result);
|
||||
} catch (android.content.ActivityNotFoundException e) {
|
||||
call.reject("Nothing can open Google Wallet", "UNAVAILABLE");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Large screens and keyboards ----
|
||||
|
||||
/** Feature keyCommands: the UI's keyboard shortcuts for the screen it shows. */
|
||||
@PluginMethod
|
||||
public void setKeyCommands(PluginCall call) {
|
||||
com.liketrek.trek.screen.TrekKeyCommands.INSTANCE.set(call.getArray("commands"));
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
/** Feature windowLayout: the window's size classes and a foldable's posture and hinge. */
|
||||
@PluginMethod
|
||||
public void getWindowLayout(PluginCall call) {
|
||||
// The last layout WindowManager reported, posture and hinge included; the bare window before the first.
|
||||
getActivity().runOnUiThread(() -> call.resolve(com.liketrek.trek.screen.TrekWindowLayout.INSTANCE.current(getActivity())));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
package com.liketrek.trek;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.interfaces.ECPrivateKey;
|
||||
import java.security.interfaces.ECPublicKey;
|
||||
import java.security.spec.ECPoint;
|
||||
import java.security.spec.ECPublicKeySpec;
|
||||
import java.util.Arrays;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.KeyAgreement;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
/**
|
||||
* Decrypts a Web Push record (RFC 8291, aes128gcm content coding of RFC 8188):
|
||||
* the same thing a browser does, and the counterpart of the iOS notification
|
||||
* extension's WebPushRecord. One record, as TREK's server always sends.
|
||||
*/
|
||||
final class TrekWebPush {
|
||||
|
||||
private TrekWebPush() {}
|
||||
|
||||
static byte[] decrypt(byte[] record, TrekPushKeys keys) throws Exception {
|
||||
if (record.length < 21) throw new IllegalArgumentException("record too short");
|
||||
byte[] salt = Arrays.copyOfRange(record, 0, 16);
|
||||
int idLength = record[20] & 0xff;
|
||||
if (idLength != 65 || record.length < 21 + idLength + 17) throw new IllegalArgumentException("unexpected header");
|
||||
byte[] senderKey = Arrays.copyOfRange(record, 21, 21 + idLength);
|
||||
byte[] ciphertext = Arrays.copyOfRange(record, 21 + idLength, record.length);
|
||||
|
||||
KeyAgreement agreement = KeyAgreement.getInstance("ECDH");
|
||||
agreement.init(keys.privateKey);
|
||||
agreement.doPhase(publicKeyFrom(senderKey, keys.privateKey), true);
|
||||
byte[] shared = agreement.generateSecret();
|
||||
|
||||
byte[] keyInfo = concat("WebPush: info\0".getBytes(StandardCharsets.US_ASCII), keys.publicKey, senderKey);
|
||||
byte[] ikm = hkdf(keys.auth, shared, keyInfo, 32);
|
||||
byte[] cek = hkdf(salt, ikm, "Content-Encoding: aes128gcm\0".getBytes(StandardCharsets.US_ASCII), 16);
|
||||
byte[] nonce = hkdf(salt, ikm, "Content-Encoding: nonce\0".getBytes(StandardCharsets.US_ASCII), 12);
|
||||
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(cek, "AES"), new GCMParameterSpec(128, nonce));
|
||||
byte[] padded = cipher.doFinal(ciphertext);
|
||||
|
||||
// Padding: the content, one delimiter (2 for the last record), then zeros.
|
||||
int end = padded.length - 1;
|
||||
while (end >= 0 && padded[end] == 0) end--;
|
||||
if (end < 0 || padded[end] != 2) throw new IllegalArgumentException("bad padding");
|
||||
return Arrays.copyOf(padded, end);
|
||||
}
|
||||
|
||||
private static ECPublicKey publicKeyFrom(byte[] point, ECPrivateKey onCurve) throws Exception {
|
||||
if (point[0] != 0x04) throw new IllegalArgumentException("compressed key");
|
||||
BigInteger x = new BigInteger(1, Arrays.copyOfRange(point, 1, 33));
|
||||
BigInteger y = new BigInteger(1, Arrays.copyOfRange(point, 33, 65));
|
||||
return (ECPublicKey) KeyFactory.getInstance("EC").generatePublic(new ECPublicKeySpec(new ECPoint(x, y), onCurve.getParams()));
|
||||
}
|
||||
|
||||
/** HKDF-SHA256 (RFC 5869) for outputs of at most one block. */
|
||||
private static byte[] hkdf(byte[] salt, byte[] ikm, byte[] info, int length) throws Exception {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(salt, "HmacSHA256"));
|
||||
byte[] prk = mac.doFinal(ikm);
|
||||
mac.init(new SecretKeySpec(prk, "HmacSHA256"));
|
||||
mac.update(info);
|
||||
mac.update((byte) 1);
|
||||
return Arrays.copyOf(mac.doFinal(), length);
|
||||
}
|
||||
|
||||
private static byte[] concat(byte[]... parts) {
|
||||
int length = 0;
|
||||
for (byte[] part : parts) length += part.length;
|
||||
byte[] out = new byte[length];
|
||||
int offset = 0;
|
||||
for (byte[] part : parts) {
|
||||
System.arraycopy(part, 0, out, offset, part.length);
|
||||
offset += part.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -24,16 +24,6 @@ class TrekWebViewClient extends BridgeWebViewClient {
|
||||
this.onUnreachable = onUnreachable;
|
||||
}
|
||||
|
||||
/** Mapbox's map resources go through the app's own store (tiles/TrekTileStore.kt). */
|
||||
@Override
|
||||
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
|
||||
if (com.liketrek.trek.tiles.TrekTileStore.INSTANCE.handles(request)) {
|
||||
WebResourceResponse stored = com.liketrek.trek.tiles.TrekTileStore.INSTANCE.serve(request);
|
||||
if (stored != null) return stored;
|
||||
}
|
||||
return super.shouldInterceptRequest(view, request);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) {
|
||||
if (request.isForMainFrame()) {
|
||||
|
||||
@@ -1,254 +0,0 @@
|
||||
package com.liketrek.trek.billing
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import com.android.billingclient.api.AcknowledgePurchaseParams
|
||||
import com.android.billingclient.api.BillingClient
|
||||
import com.android.billingclient.api.BillingClientStateListener
|
||||
import com.android.billingclient.api.BillingFlowParams
|
||||
import com.android.billingclient.api.BillingResult
|
||||
import com.android.billingclient.api.PendingPurchasesParams
|
||||
import com.android.billingclient.api.ProductDetails
|
||||
import com.android.billingclient.api.Purchase
|
||||
import com.android.billingclient.api.PurchasesUpdatedListener
|
||||
import com.android.billingclient.api.QueryProductDetailsParams
|
||||
import com.android.billingclient.api.QueryPurchasesParams
|
||||
import com.getcapacitor.JSArray
|
||||
import com.getcapacitor.JSObject
|
||||
import com.getcapacitor.PluginCall
|
||||
import com.liketrek.trek.BuildConfig
|
||||
|
||||
/**
|
||||
* TREK Cloud subscriptions through Google Play Billing, the counterpart of the
|
||||
* iOS app's StoreKit 2 (TrekShellPlugin.swift, feature storeKit). The UI drives
|
||||
* it through the same TrekShell methods (storeProducts, storePurchase,
|
||||
* storeFinish, storeRestore) behind native/iap.ts's IapClient, so the shell's
|
||||
* plan screens work unchanged; a purchase's token is the receipt the control
|
||||
* plane verifies with Google (iap-google.ts in trek-hosted).
|
||||
*
|
||||
* Products are subscriptions with the ids `cloud.<plan>.monthly|yearly`, one base
|
||||
* plan each. A purchase stays unacknowledged until storeFinish, after the
|
||||
* instance exists, so a purchase whose redeem never happened is offered again
|
||||
* by a restore rather than lost (Play refunds what is not acknowledged within
|
||||
* three days, and the control plane acknowledges too once it has verified).
|
||||
*/
|
||||
object TrekBilling {
|
||||
private var client: BillingClient? = null
|
||||
private var pendingPurchase: PluginCall? = null
|
||||
private var pendingProduct: String? = null
|
||||
private val waiting = mutableListOf<(Boolean) -> Unit>()
|
||||
private var connecting = false
|
||||
|
||||
private val listener = PurchasesUpdatedListener { result, purchases -> onPurchasesUpdated(result, purchases) }
|
||||
|
||||
/**
|
||||
* Told of a purchase that went through outside a purchase sheet: a payment
|
||||
* that was pending (cash, some carrier billing) and cleared later. The shell
|
||||
* redeems it like a restore (the "storeTransaction" event), since Play refunds
|
||||
* what is not acknowledged within three days.
|
||||
*/
|
||||
var onTransaction: ((JSObject) -> Unit)? = null
|
||||
|
||||
private fun client(context: Context): BillingClient = client ?: BillingClient.newBuilder(context.applicationContext)
|
||||
.setListener(listener)
|
||||
.enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().enablePrepaidPlans().build())
|
||||
.enableAutoServiceReconnection()
|
||||
.build()
|
||||
.also { client = it }
|
||||
|
||||
/** Runs [then] once connected; false when Play is not there (no Play Store, a sideloaded device). */
|
||||
private fun ready(context: Context, then: (Boolean) -> Unit) {
|
||||
val billing = client(context)
|
||||
if (billing.isReady) return then(true)
|
||||
synchronized(waiting) {
|
||||
waiting += then
|
||||
if (connecting) return
|
||||
connecting = true
|
||||
}
|
||||
billing.startConnection(object : BillingClientStateListener {
|
||||
override fun onBillingSetupFinished(result: BillingResult) = flush(result.responseCode == BillingClient.BillingResponseCode.OK)
|
||||
|
||||
override fun onBillingServiceDisconnected() = flush(false)
|
||||
})
|
||||
}
|
||||
|
||||
private fun flush(ok: Boolean) {
|
||||
val callbacks = synchronized(waiting) {
|
||||
connecting = false
|
||||
waiting.toList().also { waiting.clear() }
|
||||
}
|
||||
callbacks.forEach { it(ok) }
|
||||
}
|
||||
|
||||
private fun details(context: Context, ids: List<String>, done: (List<ProductDetails>) -> Unit) {
|
||||
if (ids.isEmpty()) return done(emptyList())
|
||||
val params = QueryProductDetailsParams.newBuilder()
|
||||
.setProductList(ids.map { QueryProductDetailsParams.Product.newBuilder().setProductId(it).setProductType(BillingClient.ProductType.SUBS).build() })
|
||||
.build()
|
||||
client(context).queryProductDetailsAsync(params) { result, found ->
|
||||
done(if (result.responseCode == BillingClient.BillingResponseCode.OK) found.productDetailsList else emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
/** The recurring price of a subscription's base plan, as Play formats it for the buyer. */
|
||||
internal fun displayPrice(product: ProductDetails): String? {
|
||||
val offer = baseOffer(product) ?: return null
|
||||
return offer.pricingPhases.pricingPhaseList.lastOrNull()?.formattedPrice
|
||||
}
|
||||
|
||||
/** The base plan's own offer: the one without an offer id (free trials and promotions carry one). */
|
||||
private fun baseOffer(product: ProductDetails): ProductDetails.SubscriptionOfferDetails? {
|
||||
val offers = product.subscriptionOfferDetails ?: return null
|
||||
return offers.firstOrNull { it.offerId == null } ?: offers.firstOrNull()
|
||||
}
|
||||
|
||||
fun products(context: Context, call: PluginCall) {
|
||||
val ids = call.getArray("ids")?.toList<String>().orEmpty()
|
||||
ready(context) { ok ->
|
||||
val result = JSObject().put("sandbox", BuildConfig.DEBUG)
|
||||
if (!ok) return@ready call.resolve(result.put("products", JSArray()))
|
||||
details(context, ids) { found ->
|
||||
val list = JSArray()
|
||||
for (product in found) displayPrice(product)?.let { list.put(JSObject().put("id", product.productId).put("displayPrice", it)) }
|
||||
call.resolve(result.put("products", list))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun purchase(activity: Activity, call: PluginCall) {
|
||||
val id = call.getString("productId") ?: return call.reject("productId is required")
|
||||
ready(activity) { ok ->
|
||||
if (!ok) return@ready call.resolve(JSObject().put("kind", "failed"))
|
||||
details(activity, listOf(id)) { found ->
|
||||
val product = found.firstOrNull()
|
||||
val offer = product?.let(::baseOffer)
|
||||
if (product == null || offer == null) return@details call.resolve(JSObject().put("kind", "failed"))
|
||||
pendingPurchase?.resolve(JSObject().put("kind", "cancelled"))
|
||||
pendingPurchase = call
|
||||
pendingProduct = id
|
||||
val params = BillingFlowParams.newBuilder()
|
||||
.setProductDetailsParamsList(
|
||||
listOf(BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(product).setOfferToken(offer.offerToken).build()),
|
||||
)
|
||||
.build()
|
||||
activity.runOnUiThread {
|
||||
val launch = client(activity).launchBillingFlow(activity, params)
|
||||
if (launch.responseCode != BillingClient.BillingResponseCode.OK) {
|
||||
settle(if (launch.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) "cancelled" else "failed", null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onPurchasesUpdated(result: BillingResult, purchases: List<Purchase>?) {
|
||||
if (pendingPurchase == null) {
|
||||
// No sheet waiting: a pending purchase that cleared, or a renewal. Only an
|
||||
// undelivered one matters; renewals are acknowledged already.
|
||||
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
|
||||
undelivered(purchases.orEmpty(), PREFIX)?.let { onTransaction?.invoke(receipt(it)) }
|
||||
}
|
||||
return
|
||||
}
|
||||
when (result.responseCode) {
|
||||
BillingClient.BillingResponseCode.OK -> {
|
||||
val purchase = purchases?.firstOrNull { pendingProduct in it.products } ?: purchases?.firstOrNull()
|
||||
when (purchase?.purchaseState) {
|
||||
Purchase.PurchaseState.PURCHASED -> settle("bought", purchase)
|
||||
Purchase.PurchaseState.PENDING -> settle("pending", null)
|
||||
else -> settle("failed", null)
|
||||
}
|
||||
}
|
||||
BillingClient.BillingResponseCode.USER_CANCELED -> settle("cancelled", null)
|
||||
// Already subscribed on this Google account (a reinstall, another phone, a
|
||||
// redeem that never happened): that subscription is the purchase, and the
|
||||
// shell redeems it as it would a new one.
|
||||
BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> settleOwned()
|
||||
else -> settle("failed", null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun settleOwned() {
|
||||
val call = pendingPurchase ?: return
|
||||
val params = QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.SUBS).build()
|
||||
client?.queryPurchasesAsync(params) { result, purchases ->
|
||||
val owned = if (result.responseCode == BillingClient.BillingResponseCode.OK) newest(purchases, PREFIX) else null
|
||||
if (pendingPurchase !== call) return@queryPurchasesAsync
|
||||
settle(if (owned != null) "bought" else "failed", owned)
|
||||
} ?: settle("failed", null)
|
||||
}
|
||||
|
||||
private fun receipt(purchase: Purchase): JSObject = JSObject()
|
||||
.put("purchaseToken", purchase.purchaseToken)
|
||||
.put("transactionId", purchase.purchaseToken)
|
||||
.put("productId", purchase.products.firstOrNull { it.startsWith(PREFIX) } ?: purchase.products.firstOrNull())
|
||||
|
||||
private fun settle(kind: String, purchase: Purchase?) {
|
||||
val call = pendingPurchase ?: return
|
||||
pendingPurchase = null
|
||||
val product = pendingProduct
|
||||
pendingProduct = null
|
||||
val out = JSObject().put("kind", kind)
|
||||
if (purchase != null) {
|
||||
out.put("purchaseToken", purchase.purchaseToken)
|
||||
out.put("transactionId", purchase.purchaseToken)
|
||||
out.put("productId", purchase.products.firstOrNull() ?: product)
|
||||
purchase.orderId?.let { out.put("orderId", it) }
|
||||
}
|
||||
call.resolve(out)
|
||||
}
|
||||
|
||||
/** Marks the purchase delivered, once its instance exists: acknowledged with Play. */
|
||||
fun finish(context: Context, call: PluginCall) {
|
||||
val token = call.getString("transactionId") ?: return call.reject("transactionId is required")
|
||||
ready(context) { ok ->
|
||||
if (!ok) return@ready call.resolve()
|
||||
val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(token).build()
|
||||
client(context).acknowledgePurchase(params) { call.resolve() }
|
||||
}
|
||||
}
|
||||
|
||||
/** The newest active subscription under `prefix` on this Google account, if any. */
|
||||
fun restore(context: Context, call: PluginCall) {
|
||||
val prefix = call.getString("prefix") ?: "cloud."
|
||||
ready(context) { ok ->
|
||||
if (!ok) return@ready call.resolve(JSObject())
|
||||
val params = QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.SUBS).build()
|
||||
client(context).queryPurchasesAsync(params) { result, purchases ->
|
||||
val found = if (result.responseCode == BillingClient.BillingResponseCode.OK) newest(purchases, prefix) else null
|
||||
if (found == null) return@queryPurchasesAsync call.resolve(JSObject())
|
||||
call.resolve(
|
||||
JSObject()
|
||||
.put("purchaseToken", found.purchaseToken)
|
||||
.put("transactionId", found.purchaseToken)
|
||||
.put("productId", found.products.first { it.startsWith(prefix) }),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A paid cloud purchase that was never delivered (not acknowledged), for the
|
||||
* shell to redeem at its start: one that cleared while the app was closed.
|
||||
*/
|
||||
fun unfinished(context: Context, call: PluginCall) {
|
||||
val prefix = call.getString("prefix") ?: PREFIX
|
||||
ready(context) { ok ->
|
||||
if (!ok) return@ready call.resolve(JSObject())
|
||||
val params = QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.SUBS).build()
|
||||
client(context).queryPurchasesAsync(params) { result, purchases ->
|
||||
val found = if (result.responseCode == BillingClient.BillingResponseCode.OK) undelivered(purchases, prefix) else null
|
||||
call.resolve(found?.let(::receipt) ?: JSObject())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun undelivered(purchases: List<Purchase>, prefix: String): Purchase? =
|
||||
newest(purchases.filter { !it.isAcknowledged }, prefix)
|
||||
|
||||
private const val PREFIX = "cloud."
|
||||
|
||||
internal fun newest(purchases: List<Purchase>, prefix: String): Purchase? = purchases
|
||||
.filter { p -> p.purchaseState == Purchase.PurchaseState.PURCHASED && p.products.any { it.startsWith(prefix) } }
|
||||
.maxByOrNull { it.purchaseTime }
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
package com.liketrek.trek.location
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.location.Location
|
||||
import android.location.LocationManager
|
||||
import android.os.Build
|
||||
import androidx.core.location.LocationListenerCompat
|
||||
import androidx.core.location.LocationManagerCompat
|
||||
import androidx.core.location.LocationRequestCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.getcapacitor.JSObject
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* One location stream for the UI's navigator.geolocation (native/geolocation.ts),
|
||||
* the counterpart of TrekLocation in TrekShellPlugin.swift: the system's fused
|
||||
* provider where there is one (Android 12+), GPS and network before, with
|
||||
* positions and errors shaped like the Geolocation API's. Asking for the
|
||||
* permission is the plugin's part; this starts once it is granted.
|
||||
*/
|
||||
class TrekLocation(context: Context, private val emit: (JSObject) -> Unit) {
|
||||
private val manager = context.getSystemService(LocationManager::class.java)
|
||||
private val executor = ContextCompat.getMainExecutor(context)
|
||||
private val listeners = mutableListOf<LocationListenerCompat>()
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun start(highAccuracy: Boolean) {
|
||||
stop()
|
||||
if (manager == null || !LocationManagerCompat.isLocationEnabled(manager)) {
|
||||
emitError(2, "Location services are off")
|
||||
return
|
||||
}
|
||||
val request = LocationRequestCompat.Builder(if (highAccuracy) 2_000L else 10_000L)
|
||||
.setQuality(if (highAccuracy) LocationRequestCompat.QUALITY_HIGH_ACCURACY else LocationRequestCompat.QUALITY_BALANCED_POWER_ACCURACY)
|
||||
.setMinUpdateDistanceMeters(if (highAccuracy) 2f else 25f)
|
||||
.build()
|
||||
val providers = when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && manager.hasProvider(LocationManager.FUSED_PROVIDER) -> listOf(LocationManager.FUSED_PROVIDER)
|
||||
else -> listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER).filter { manager.isProviderEnabled(it) }
|
||||
}
|
||||
if (providers.isEmpty()) {
|
||||
emitError(2, "No location provider")
|
||||
return
|
||||
}
|
||||
// The last known fix answers getCurrentPosition at once; a fresh one follows.
|
||||
providers.mapNotNull { runCatching { manager.getLastKnownLocation(it) }.getOrNull() }
|
||||
.maxByOrNull { it.time }
|
||||
?.takeIf { System.currentTimeMillis() - it.time < 120_000 }
|
||||
?.let { emit(payload(it)) }
|
||||
for (provider in providers) {
|
||||
val listener = LocationListenerCompat { location -> emit(payload(location)) }
|
||||
try {
|
||||
LocationManagerCompat.requestLocationUpdates(manager, provider, request, executor, listener)
|
||||
listeners += listener
|
||||
} catch (e: SecurityException) {
|
||||
emitError(1, "Location permission denied")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Removing updates needs no permission of its own; one revoked meanwhile only means there is nothing left to stop.
|
||||
@SuppressLint("MissingPermission")
|
||||
fun stop() {
|
||||
val m = manager ?: return
|
||||
for (listener in listeners) {
|
||||
try {
|
||||
LocationManagerCompat.removeUpdates(m, listener)
|
||||
} catch (e: SecurityException) {
|
||||
// Nothing to stop.
|
||||
}
|
||||
}
|
||||
listeners.clear()
|
||||
}
|
||||
|
||||
fun emitError(code: Int, message: String) {
|
||||
emit(JSObject().put("error", JSObject().put("code", code).put("message", message)))
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun payload(location: Location): JSObject = JSObject()
|
||||
.put("latitude", location.latitude)
|
||||
.put("longitude", location.longitude)
|
||||
.put("accuracy", location.accuracy.toDouble())
|
||||
.put("altitude", if (location.hasAltitude()) location.altitude else JSONObject.NULL)
|
||||
.put("speed", if (location.hasSpeed()) location.speed.toDouble() else JSONObject.NULL)
|
||||
.put("heading", if (location.hasBearing()) location.bearing.toDouble() else JSONObject.NULL)
|
||||
.put("timestamp", location.time.toDouble())
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
package com.liketrek.trek.screen
|
||||
|
||||
import android.view.KeyEvent
|
||||
import android.view.KeyboardShortcutGroup
|
||||
import android.view.KeyboardShortcutInfo
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* The UI's keyboard shortcuts as the app's own (TrekShell.setKeyCommands, feature
|
||||
* keyCommands), the Android side of TrekKeyCommands in the iOS shell: a hardware
|
||||
* keyboard's shortcut runs the UI's command even while the focus sits outside the
|
||||
* page, and the system's shortcut list (Meta + /) shows them under their names in
|
||||
* the UI's language. Each screen replaces the list, so only what works here is on it.
|
||||
*
|
||||
* "command" is Ctrl on Android (Meta is accepted too, for keyboards made for a Mac),
|
||||
* "option" is Alt.
|
||||
*/
|
||||
object TrekKeyCommands {
|
||||
data class Command(val id: String, val title: String, val keyCode: Int, val modifiers: Int)
|
||||
|
||||
@Volatile var current: List<Command> = emptyList()
|
||||
private set
|
||||
|
||||
var onPress: ((String) -> Unit)? = null
|
||||
|
||||
fun set(raw: JSONArray?) {
|
||||
current = parse(raw)
|
||||
}
|
||||
|
||||
fun parse(raw: JSONArray?): List<Command> {
|
||||
if (raw == null) return emptyList()
|
||||
return (0 until raw.length()).mapNotNull { i ->
|
||||
val o: JSONObject = raw.optJSONObject(i) ?: return@mapNotNull null
|
||||
val id = o.optString("id").ifEmpty { return@mapNotNull null }
|
||||
val title = o.optString("title").ifEmpty { return@mapNotNull null }
|
||||
val key = keyCode(o.optString("input")) ?: return@mapNotNull null
|
||||
var meta = 0
|
||||
val mods = o.optJSONArray("modifiers")
|
||||
for (j in 0 until (mods?.length() ?: 0)) {
|
||||
meta = meta or when (mods!!.optString(j)) {
|
||||
"command", "control" -> KeyEvent.META_CTRL_ON
|
||||
"shift" -> KeyEvent.META_SHIFT_ON
|
||||
"option" -> KeyEvent.META_ALT_ON
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
Command(id, title, key, meta)
|
||||
}
|
||||
}
|
||||
|
||||
/** The key a UI input names: a letter, a digit, an arrow, escape or a punctuation mark. */
|
||||
fun keyCode(input: String): Int? = when (input.lowercase()) {
|
||||
"left" -> KeyEvent.KEYCODE_DPAD_LEFT
|
||||
"right" -> KeyEvent.KEYCODE_DPAD_RIGHT
|
||||
"up" -> KeyEvent.KEYCODE_DPAD_UP
|
||||
"down" -> KeyEvent.KEYCODE_DPAD_DOWN
|
||||
"escape" -> KeyEvent.KEYCODE_ESCAPE
|
||||
"enter", "\r" -> KeyEvent.KEYCODE_ENTER
|
||||
"," -> KeyEvent.KEYCODE_COMMA
|
||||
"." -> KeyEvent.KEYCODE_PERIOD
|
||||
"/" -> KeyEvent.KEYCODE_SLASH
|
||||
"[" -> KeyEvent.KEYCODE_LEFT_BRACKET
|
||||
"]" -> KeyEvent.KEYCODE_RIGHT_BRACKET
|
||||
"-" -> KeyEvent.KEYCODE_MINUS
|
||||
"=" -> KeyEvent.KEYCODE_EQUALS
|
||||
" ", "space" -> KeyEvent.KEYCODE_SPACE
|
||||
else -> when {
|
||||
input.length == 1 && input[0].lowercaseChar() in 'a'..'z' -> KeyEvent.KEYCODE_A + (input[0].lowercaseChar() - 'a')
|
||||
input.length == 1 && input[0] in '0'..'9' -> KeyEvent.KEYCODE_0 + (input[0] - '0')
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private const val MASK = KeyEvent.META_CTRL_ON or KeyEvent.META_SHIFT_ON or KeyEvent.META_ALT_ON
|
||||
|
||||
/** The command a key press stands for, if any. Meta counts as Ctrl. */
|
||||
fun match(keyCode: Int, metaState: Int): Command? {
|
||||
var state = metaState
|
||||
if (state and KeyEvent.META_META_ON != 0) state = state or KeyEvent.META_CTRL_ON
|
||||
val pressed = state and MASK
|
||||
return current.firstOrNull { it.keyCode == keyCode && it.modifiers == pressed }
|
||||
}
|
||||
|
||||
/** Runs the matching command for a key going down. True when one took it. */
|
||||
fun dispatch(event: KeyEvent): Boolean {
|
||||
if (event.action != KeyEvent.ACTION_DOWN || event.repeatCount > 0) return false
|
||||
val command = match(event.keyCode, event.metaState) ?: return false
|
||||
// A plain key (no modifier) is left to a text field that has the focus.
|
||||
if (command.modifiers == 0 && event.keyCode != KeyEvent.KEYCODE_ESCAPE) return false
|
||||
val press = onPress ?: return false
|
||||
press(command.id)
|
||||
return true
|
||||
}
|
||||
|
||||
/** The list the system shows for Meta + /. */
|
||||
fun shortcutGroup(label: CharSequence): KeyboardShortcutGroup? {
|
||||
val items = current.map { KeyboardShortcutInfo(it.title, it.keyCode, it.modifiers) }
|
||||
return if (items.isEmpty()) null else KeyboardShortcutGroup(label, items)
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package com.liketrek.trek.screen
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Rect
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.window.layout.FoldingFeature
|
||||
import androidx.window.layout.WindowInfoTracker
|
||||
import androidx.window.layout.WindowLayoutInfo
|
||||
import androidx.window.layout.WindowMetricsCalculator
|
||||
import com.getcapacitor.JSObject
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* The window's shape for the UI (TrekShell.getWindowLayout and the
|
||||
* "windowLayout" event, feature windowLayout): its size class by Android's
|
||||
* breakpoints, and a foldable's posture and hinge through Jetpack WindowManager.
|
||||
*
|
||||
* The layouts themselves follow the CSS size classes (hooks/sizeClass.ts), which
|
||||
* the WebView answers from the window like any browser; this adds what CSS cannot
|
||||
* see: that a Galaxy Z Fold or Pixel Fold is half open like a laptop or a book,
|
||||
* and where the hinge runs, so a layout can keep content off the fold. Lengths
|
||||
* are in CSS pixels, relative to the window.
|
||||
*/
|
||||
object TrekWindowLayout {
|
||||
/** The last WindowLayoutInfo WindowManager reported, with the fold in it. */
|
||||
@Volatile private var lastInfo: WindowLayoutInfo? = null
|
||||
|
||||
/** The window now, with the posture last reported: what getWindowLayout answers. */
|
||||
fun current(activity: Activity): JSObject = describe(activity, lastInfo)
|
||||
|
||||
fun observe(activity: ComponentActivity, emit: (JSObject) -> Unit) {
|
||||
activity.lifecycleScope.launch {
|
||||
activity.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
WindowInfoTracker.getOrCreate(activity).windowLayoutInfo(activity).collect { info ->
|
||||
lastInfo = info
|
||||
emit(describe(activity, info))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun describe(activity: Activity, info: WindowLayoutInfo?): JSObject {
|
||||
val density = activity.resources.displayMetrics.density
|
||||
val bounds = WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(activity).bounds
|
||||
val widthDp = bounds.width() / density
|
||||
val heightDp = bounds.height() / density
|
||||
val result = JSObject()
|
||||
.put("width", widthDp.toInt())
|
||||
.put("height", heightDp.toInt())
|
||||
.put("widthClass", sizeClass(widthDp, 600f, 840f))
|
||||
.put("heightClass", sizeClass(heightDp, 480f, 900f))
|
||||
val fold = info?.displayFeatures?.filterIsInstance<FoldingFeature>()?.firstOrNull()
|
||||
if (fold == null) {
|
||||
result.put("posture", "none")
|
||||
} else {
|
||||
val r: Rect = fold.bounds
|
||||
result.put("posture", if (fold.state == FoldingFeature.State.HALF_OPENED) "halfOpened" else "flat")
|
||||
.put("orientation", if (fold.orientation == FoldingFeature.Orientation.VERTICAL) "vertical" else "horizontal")
|
||||
.put("separating", fold.isSeparating)
|
||||
.put("occluding", fold.occlusionType == FoldingFeature.OcclusionType.FULL)
|
||||
.put(
|
||||
"hinge",
|
||||
JSObject()
|
||||
.put("left", ((r.left - bounds.left) / density).toInt())
|
||||
.put("top", ((r.top - bounds.top) / density).toInt())
|
||||
.put("right", ((r.right - bounds.left) / density).toInt())
|
||||
.put("bottom", ((r.bottom - bounds.top) / density).toInt()),
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Android's window size classes: compact, medium, expanded. */
|
||||
fun sizeClass(dp: Float, medium: Float, expanded: Float): String = when {
|
||||
dp < medium -> "compact"
|
||||
dp < expanded -> "medium"
|
||||
else -> "expanded"
|
||||
}
|
||||
}
|
||||
@@ -1,431 +0,0 @@
|
||||
package com.liketrek.trek.share
|
||||
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Bundle
|
||||
import android.text.format.Formatter
|
||||
import android.view.HapticFeedbackConstants
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.CalendarMonth
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
import androidx.compose.material.icons.rounded.Description
|
||||
import androidx.compose.material.icons.rounded.Email
|
||||
import androidx.compose.material.icons.rounded.Image
|
||||
import androidx.compose.material.icons.rounded.Language
|
||||
import androidx.compose.material.icons.rounded.PictureAsPdf
|
||||
import androidx.compose.material.icons.rounded.Schedule
|
||||
import androidx.compose.material.icons.rounded.ShortText
|
||||
import androidx.compose.material.icons.rounded.Wallet
|
||||
import androidx.compose.material.icons.rounded.ErrorOutline
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.liketrek.trek.MainActivity
|
||||
import com.liketrek.trek.R
|
||||
import com.liketrek.trek.ui.TrekMaterialTheme
|
||||
import com.liketrek.trek.widget.WidgetArt
|
||||
import java.time.LocalDate
|
||||
import java.time.temporal.ChronoUnit
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* "TREK" in the system share sheet (and "Open with TREK"): a Material bottom
|
||||
* sheet over the app that shared, showing what came in, the trip it goes to and
|
||||
* one button, the counterpart of the iOS share extension (ShareView.swift). The
|
||||
* item and the trip are left for the app (ShareHandoff) and TREK opens, where
|
||||
* the booking import takes over. A direct-share shortcut of a trip
|
||||
* (TrekShortcuts) arrives with that trip already picked.
|
||||
*/
|
||||
class ShareActivity : ComponentActivity() {
|
||||
private sealed interface Phase {
|
||||
data object Loading : Phase
|
||||
data object Ready : Phase
|
||||
data object Saving : Phase
|
||||
data object Done : Phase
|
||||
data class Failed(val message: Int) : Phase
|
||||
}
|
||||
|
||||
private var phase by mutableStateOf<Phase>(Phase.Loading)
|
||||
private var item by mutableStateOf<ShareItem?>(null)
|
||||
private var trips by mutableStateOf<List<ShareHandoff.Trip>>(emptyList())
|
||||
private var selected by mutableStateOf<Long?>(null)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
trips = ShareHandoff.trips(this)
|
||||
val shortcutTrip = intent.getStringExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID)?.removePrefix("trip.")?.toLongOrNull()
|
||||
selected = if (savedInstanceState?.containsKey(SELECTED) == true) {
|
||||
// The trip picked before the system recreated the sheet; -1 is "choose later".
|
||||
savedInstanceState.getLong(SELECTED).takeIf { it >= 0 }
|
||||
} else {
|
||||
shortcutTrip?.takeIf { id -> trips.any { it.id == id } } ?: trips.firstOrNull()?.id
|
||||
}
|
||||
val incoming = intent
|
||||
lifecycleScope.launch {
|
||||
phase = try {
|
||||
item = withContext(Dispatchers.IO) { ShareLoader.load(incoming, contentResolver) }
|
||||
Phase.Ready
|
||||
} catch (e: ShareLoadException) {
|
||||
Phase.Failed(if (e.reason == ShareLoadException.Reason.TOO_LARGE) R.string.share_too_large else R.string.share_unsupported)
|
||||
} catch (e: Exception) {
|
||||
Phase.Failed(R.string.share_unsupported)
|
||||
}
|
||||
}
|
||||
setContent {
|
||||
TrekMaterialTheme {
|
||||
Sheet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.putLong(SELECTED, selected ?: -1L)
|
||||
}
|
||||
|
||||
private fun add() {
|
||||
val current = item ?: return
|
||||
if (phase != Phase.Ready) return
|
||||
phase = Phase.Saving
|
||||
lifecycleScope.launch {
|
||||
val stored = withContext(Dispatchers.IO) { ShareHandoff.store(this@ShareActivity, current, selected) }
|
||||
if (!stored) {
|
||||
window.decorView.performHapticFeedback(HapticFeedbackConstants.REJECT)
|
||||
phase = Phase.Failed(R.string.share_failed)
|
||||
return@launch
|
||||
}
|
||||
window.decorView.performHapticFeedback(HapticFeedbackConstants.CONFIRM)
|
||||
phase = Phase.Done
|
||||
delay(850)
|
||||
startActivity(
|
||||
Intent(this@ShareActivity, MainActivity::class.java)
|
||||
.setAction(MainActivity.ACTION_SHARE_HANDOFF)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP),
|
||||
)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun Sheet() {
|
||||
val state = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val scope = rememberCoroutineScope()
|
||||
val dismiss: () -> Unit = {
|
||||
scope.launch { state.hide() }.invokeOnCompletion { finish() }
|
||||
}
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { finish() },
|
||||
sheetState = state,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().navigationBarsPadding().padding(bottom = 12.dp)) {
|
||||
Header(onClose = dismiss)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
when (val now = phase) {
|
||||
is Phase.Failed -> Failure(stringResource(now.message), onClose = dismiss)
|
||||
else -> Content(now)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Header(onClose: () -> Unit) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).background(MaterialTheme.colorScheme.primary),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text("T", color = MaterialTheme.colorScheme.onPrimary, fontWeight = FontWeight.Black, fontSize = 21.sp)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("TREK", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, letterSpacing = 2.4.sp)
|
||||
Text(stringResource(R.string.share_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
TextButton(onClick = onClose) { Text(stringResource(R.string.share_cancel)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Content(now: Phase) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||
ItemCard(item, Modifier.padding(horizontal = 20.dp))
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.share_trip).uppercase(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.2.sp,
|
||||
modifier = Modifier.padding(horizontal = 22.dp),
|
||||
)
|
||||
if (trips.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.share_no_trips),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 22.dp),
|
||||
)
|
||||
} else {
|
||||
TripRail()
|
||||
}
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.share_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 22.dp),
|
||||
)
|
||||
AddButton(now)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ItemCard(item: ShareItem?, modifier: Modifier) {
|
||||
val context = LocalContext.current
|
||||
Surface(
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(modifier = Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier.size(46.dp).clip(RoundedCornerShape(14.dp)).background(MaterialTheme.colorScheme.secondaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (item == null) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Icon(kindIcon(item.kind), null, tint = MaterialTheme.colorScheme.onSecondaryContainer)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
item?.title ?: stringResource(R.string.share_reading),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (item != null) {
|
||||
val parts = listOfNotNull(stringResource(kindLabel(item.kind)), Formatter.formatShortFileSize(context, item.data.size.toLong()), item.source)
|
||||
Text(parts.joinToString(", "), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TripRail() {
|
||||
LazyRow(contentPadding = PaddingValues(horizontal = 20.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
items(trips, key = { it.id }) { trip ->
|
||||
TripCard(trip, selected == trip.id) { selected = trip.id }
|
||||
}
|
||||
item {
|
||||
FilterChip(
|
||||
selected = selected == null,
|
||||
onClick = { selected = null },
|
||||
label = { Text(stringResource(R.string.share_later)) },
|
||||
leadingIcon = { Icon(Icons.Rounded.Schedule, null, modifier = Modifier.size(18.dp)) },
|
||||
modifier = Modifier.height(132.dp).padding(vertical = 44.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TripCard(trip: ShareHandoff.Trip, isSelected: Boolean, onClick: () -> Unit) {
|
||||
val view = LocalView.current
|
||||
val cover: Bitmap? = remember(trip.id) { WidgetArt.cover(trip.cover, 360) ?: WidgetArt.sky(trip.title, 300, 200) }
|
||||
val border = if (isSelected) BorderStroke(3.dp, MaterialTheme.colorScheme.primary) else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
|
||||
Surface(
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
border = border,
|
||||
modifier = Modifier.width(168.dp).height(132.dp).clip(RoundedCornerShape(20.dp)).clickable {
|
||||
view.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)
|
||||
onClick()
|
||||
},
|
||||
) {
|
||||
Box {
|
||||
if (cover != null) {
|
||||
Image(cover.asImageBitmap(), null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(
|
||||
Brush.verticalGradient(listOf(Color.Black.copy(alpha = 0.1f), Color.Black.copy(alpha = 0.68f))),
|
||||
),
|
||||
)
|
||||
if (isSelected) {
|
||||
Box(
|
||||
modifier = Modifier.padding(8.dp).size(24.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary).align(Alignment.TopEnd),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Icons.Rounded.Check, null, tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.align(Alignment.BottomStart).padding(12.dp)) {
|
||||
Text(when_(trip), color = Color.White.copy(alpha = 0.82f), style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold)
|
||||
Text(trip.title, color = Color.White, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun when_(trip: ShareHandoff.Trip): String {
|
||||
val today = LocalDate.now()
|
||||
val start = trip.start
|
||||
val end = trip.end
|
||||
return when {
|
||||
end != null && end < today -> stringResource(R.string.share_past)
|
||||
start == null || start <= today -> stringResource(R.string.share_now)
|
||||
ChronoUnit.DAYS.between(today, start) == 1L -> stringResource(R.string.share_tomorrow)
|
||||
else -> stringResource(R.string.share_in_days, ChronoUnit.DAYS.between(today, start).toInt())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddButton(now: Phase) {
|
||||
val title = trips.firstOrNull { it.id == selected }?.title
|
||||
Button(
|
||||
onClick = ::add,
|
||||
enabled = now == Phase.Ready,
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
colors = if (now == Phase.Done) {
|
||||
ButtonDefaults.buttonColors(containerColor = Color(0xFF2FA37A), disabledContainerColor = Color(0xFF2FA37A), disabledContentColor = Color.White)
|
||||
} else {
|
||||
ButtonDefaults.buttonColors()
|
||||
},
|
||||
contentPadding = PaddingValues(vertical = 16.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp),
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = now,
|
||||
transitionSpec = { (fadeIn() + scaleIn(spring(dampingRatio = 0.6f))) togetherWith fadeOut() },
|
||||
label = "add",
|
||||
) { target ->
|
||||
when (target) {
|
||||
Phase.Saving, Phase.Loading -> CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary)
|
||||
Phase.Done -> Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Rounded.Check, null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.share_added), fontWeight = FontWeight.Bold)
|
||||
}
|
||||
else -> Text(
|
||||
if (title != null) stringResource(R.string.share_add_to, title) else stringResource(R.string.share_add),
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Failure(message: String, onClose: () -> Unit) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(20.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(Icons.Rounded.ErrorOutline, null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(40.dp))
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(message, style = MaterialTheme.typography.bodyLarge)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Button(onClick = onClose, shape = RoundedCornerShape(18.dp)) { Text(stringResource(R.string.share_close)) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun kindIcon(kind: ShareItem.Kind): ImageVector = when (kind) {
|
||||
ShareItem.Kind.PASS -> Icons.Rounded.Wallet
|
||||
ShareItem.Kind.PDF -> Icons.Rounded.PictureAsPdf
|
||||
ShareItem.Kind.MAIL -> Icons.Rounded.Email
|
||||
ShareItem.Kind.CALENDAR -> Icons.Rounded.CalendarMonth
|
||||
ShareItem.Kind.PAGE -> Icons.Rounded.Language
|
||||
ShareItem.Kind.TEXT -> Icons.Rounded.ShortText
|
||||
ShareItem.Kind.IMAGE -> Icons.Rounded.Image
|
||||
ShareItem.Kind.FILE -> Icons.Rounded.Description
|
||||
}
|
||||
|
||||
private fun kindLabel(kind: ShareItem.Kind): Int = when (kind) {
|
||||
ShareItem.Kind.PASS -> R.string.share_kind_pass
|
||||
ShareItem.Kind.PDF -> R.string.share_kind_pdf
|
||||
ShareItem.Kind.MAIL -> R.string.share_kind_mail
|
||||
ShareItem.Kind.CALENDAR -> R.string.share_kind_file
|
||||
ShareItem.Kind.PAGE -> R.string.share_kind_page
|
||||
ShareItem.Kind.TEXT -> R.string.share_kind_text
|
||||
ShareItem.Kind.IMAGE -> R.string.share_kind_image
|
||||
ShareItem.Kind.FILE -> R.string.share_kind_file
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SELECTED = "trek.share.selected"
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package com.liketrek.trek.share
|
||||
|
||||
import android.content.Context
|
||||
import com.liketrek.trek.widget.WidgetSnapshot
|
||||
import com.liketrek.trek.widget.WidgetStore
|
||||
import java.io.File
|
||||
import java.time.LocalDate
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* The trips the share sheet offers, and the hand-over of the shared item to the
|
||||
* app. The trip list is the one the app keeps for the widgets (WidgetStore, key
|
||||
* "trips"); the item waits in the app's cache until MainActivity hands it to the
|
||||
* UI (TrekSharedFiles.receiveHandoff), the way ShareHandoff.swift does through
|
||||
* the keychain on iOS.
|
||||
*/
|
||||
object ShareHandoff {
|
||||
data class Trip(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
val start: LocalDate?,
|
||||
val end: LocalDate?,
|
||||
val place: String?,
|
||||
/** The small cover from the trip's widget snapshot, base64, when the app wrote one. */
|
||||
val cover: String?,
|
||||
)
|
||||
|
||||
/** The trips that have not ended yet, soonest first, then the six most recent past ones. */
|
||||
fun trips(context: Context, today: LocalDate = LocalDate.now()): List<Trip> {
|
||||
val all = WidgetStore.trips(context).map { ref ->
|
||||
val snapshot = WidgetStore.read(context, "trip.${ref.id}")?.let(WidgetSnapshot::parse)
|
||||
Trip(ref.id, ref.title, WidgetSnapshot.day(ref.start), WidgetSnapshot.day(ref.end), snapshot?.trip?.place, snapshot?.trip?.cover)
|
||||
}
|
||||
return order(all, today)
|
||||
}
|
||||
|
||||
internal fun order(all: List<Trip>, today: LocalDate): List<Trip> {
|
||||
val ahead = all.filter { (it.end ?: LocalDate.MAX) >= today }.sortedBy { it.start ?: LocalDate.MAX }
|
||||
val past = all.filter { (it.end ?: LocalDate.MAX) < today }.sortedByDescending { it.end ?: LocalDate.MIN }
|
||||
return ahead + past.take(6)
|
||||
}
|
||||
|
||||
private fun dir(context: Context) = File(context.cacheDir, "share").apply { mkdirs() }
|
||||
|
||||
/** Leaves the item for the app, with the trip it goes to (null: the app asks). */
|
||||
fun store(context: Context, item: ShareItem, tripId: Long?): Boolean = runCatching {
|
||||
val meta = JSONObject().put("name", item.name).put("mimeType", item.mimeType)
|
||||
if (tripId != null) meta.put("tripId", tripId)
|
||||
File(dir(context), "pending.data").writeBytes(item.data)
|
||||
File(dir(context), "pending.json").writeText(meta.toString())
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
|
||||
/** The item left by [store], taken once. */
|
||||
fun take(context: Context): Pair<JSONObject, ByteArray>? {
|
||||
val metaFile = File(dir(context), "pending.json")
|
||||
val dataFile = File(dir(context), "pending.data")
|
||||
if (!metaFile.exists() || !dataFile.exists()) return null
|
||||
return try {
|
||||
JSONObject(metaFile.readText()) to dataFile.readBytes()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
} finally {
|
||||
metaFile.delete()
|
||||
dataFile.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
package com.liketrek.trek.share
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.OpenableColumns
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
/**
|
||||
* What was shared to TREK, read into a file the booking import takes: a wallet
|
||||
* pass, a PDF, a mail, a calendar entry, a web page, a piece of text or a photo.
|
||||
* The import in the app (BookingImportModal) decides what it can read; this only
|
||||
* carries the bytes over with a name and a type, like ShareItem.swift on iOS.
|
||||
*/
|
||||
data class ShareItem(
|
||||
val kind: Kind,
|
||||
/** The file name the import sees. */
|
||||
val name: String,
|
||||
val mimeType: String,
|
||||
val data: ByteArray,
|
||||
/** What the sheet shows: a page title, a file name, the first line of a text. */
|
||||
val title: String,
|
||||
/** A host, for a page. */
|
||||
val source: String? = null,
|
||||
) {
|
||||
enum class Kind { PASS, PDF, MAIL, CALENDAR, PAGE, TEXT, IMAGE, FILE }
|
||||
|
||||
override fun equals(other: Any?): Boolean = other is ShareItem && other.name == name && other.data.contentEquals(data)
|
||||
|
||||
override fun hashCode(): Int = name.hashCode() * 31 + data.contentHashCode()
|
||||
}
|
||||
|
||||
class ShareLoadException(val reason: Reason) : Exception(reason.name) {
|
||||
enum class Reason { UNSUPPORTED, TOO_LARGE }
|
||||
}
|
||||
|
||||
object ShareLoader {
|
||||
/** The same ceiling as the app's import of an opened file (TrekSharedFiles). */
|
||||
const val MAX_BYTES = 15 * 1024 * 1024
|
||||
private const val PAGE_TIMEOUT_MS = 10_000
|
||||
|
||||
/** Reads what [intent] carries. Runs on a background thread: it may read a file or fetch a page. */
|
||||
fun load(intent: Intent, resolver: ContentResolver): ShareItem {
|
||||
val stream = streamOf(intent)
|
||||
if (stream != null) return fromUri(stream, intent.type, resolver)
|
||||
val text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString()?.trim()
|
||||
if (!text.isNullOrEmpty()) return fromText(text, intent.getStringExtra(Intent.EXTRA_SUBJECT))
|
||||
throw ShareLoadException(ShareLoadException.Reason.UNSUPPORTED)
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared document, content:// only. The share target is open to every app,
|
||||
* and a file:// path would be read with TREK's rights, its own private files
|
||||
* included; every sender today shares through a content provider anyway.
|
||||
*/
|
||||
private fun streamOf(intent: Intent): Uri? = when (intent.action) {
|
||||
Intent.ACTION_VIEW -> intent.data
|
||||
Intent.ACTION_SEND -> parcelable(intent, Intent.EXTRA_STREAM)
|
||||
Intent.ACTION_SEND_MULTIPLE -> parcelableList(intent)?.firstOrNull()
|
||||
else -> null
|
||||
}?.takeIf(::isShareable)
|
||||
|
||||
internal fun isShareable(uri: Uri): Boolean = uri.scheme == ContentResolver.SCHEME_CONTENT
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun parcelable(intent: Intent, key: String): Uri? =
|
||||
if (Build.VERSION.SDK_INT >= 33) intent.getParcelableExtra(key, Uri::class.java) else intent.getParcelableExtra(key)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun parcelableList(intent: Intent): List<Uri>? =
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
||||
} else {
|
||||
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
|
||||
}
|
||||
|
||||
private fun fromUri(uri: Uri, intentType: String?, resolver: ContentResolver): ShareItem {
|
||||
val name = displayName(uri, resolver)
|
||||
val size = runCatching {
|
||||
resolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)?.use { if (it.moveToFirst() && !it.isNull(0)) it.getLong(0) else null }
|
||||
}.getOrNull()
|
||||
if (size != null && size > MAX_BYTES) throw ShareLoadException(ShareLoadException.Reason.TOO_LARGE)
|
||||
val data = resolver.openInputStream(uri)?.use { readCapped(it) } ?: throw ShareLoadException(ShareLoadException.Reason.UNSUPPORTED)
|
||||
val mime = resolver.getType(uri) ?: intentType ?: "application/octet-stream"
|
||||
val (kind, normalized) = classify(name, mime)
|
||||
return checked(ShareItem(kind, withExtension(name, kind), normalized, data, name.substringBeforeLast('.').ifBlank { name }))
|
||||
}
|
||||
|
||||
/** A link becomes the page it points at; anything else, a text file the import reads. */
|
||||
internal fun fromText(text: String, subject: String?): ShareItem {
|
||||
val link = firstLink(text)
|
||||
if (link != null) {
|
||||
runCatching { return page(link, subject) }
|
||||
}
|
||||
val firstLine = text.lineSequence().firstOrNull { it.isNotBlank() }?.trim() ?: text
|
||||
return checked(ShareItem(ShareItem.Kind.TEXT, "shared-text.txt", "text/plain", text.toByteArray(), (subject ?: firstLine).take(80)))
|
||||
}
|
||||
|
||||
/** The first http(s) link in a text, when the text is not much more than that link. */
|
||||
internal fun firstLink(text: String): String? {
|
||||
val match = Regex("https?://[^\\s<>\"]+").find(text) ?: return null
|
||||
// A long mail body with a link in it is the booking itself, not a pointer to one.
|
||||
return if (text.length - match.value.length <= 200) match.value.trimEnd('.', ',', ')', ']') else null
|
||||
}
|
||||
|
||||
private fun page(link: String, subject: String?): ShareItem {
|
||||
val connection = URL(link).openConnection() as HttpURLConnection
|
||||
connection.connectTimeout = PAGE_TIMEOUT_MS
|
||||
connection.readTimeout = PAGE_TIMEOUT_MS
|
||||
connection.instanceFollowRedirects = true
|
||||
connection.setRequestProperty("Accept", "text/html")
|
||||
try {
|
||||
val code = connection.responseCode
|
||||
val type = connection.contentType.orEmpty()
|
||||
if (code !in 200..299 || !type.contains("html")) throw ShareLoadException(ShareLoadException.Reason.UNSUPPORTED)
|
||||
val data = connection.inputStream.use { readCapped(it) }
|
||||
val html = String(data, Charsets.UTF_8)
|
||||
val host = runCatching { Uri.parse(link).host }.getOrNull()
|
||||
val title = pageTitle(html) ?: subject ?: host ?: "Page"
|
||||
return checked(ShareItem(ShareItem.Kind.PAGE, "${slug(title)}.html", "text/html", data, title, host))
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/** The kind and the MIME type the import should see, from the name when the type is vague. */
|
||||
internal fun classify(name: String, mime: String): Pair<ShareItem.Kind, String> {
|
||||
val ext = name.substringAfterLast('.', "").lowercase()
|
||||
return when {
|
||||
ext == "pkpass" || mime == "application/vnd.apple.pkpass" -> ShareItem.Kind.PASS to "application/vnd.apple.pkpass"
|
||||
ext == "pdf" || mime == "application/pdf" -> ShareItem.Kind.PDF to "application/pdf"
|
||||
ext == "eml" || mime == "message/rfc822" -> ShareItem.Kind.MAIL to "message/rfc822"
|
||||
ext == "ics" || mime == "text/calendar" -> ShareItem.Kind.CALENDAR to "text/calendar"
|
||||
ext == "html" || ext == "htm" || mime == "text/html" -> ShareItem.Kind.PAGE to "text/html"
|
||||
mime.startsWith("image/") -> ShareItem.Kind.IMAGE to mime
|
||||
ext == "txt" || mime == "text/plain" -> ShareItem.Kind.TEXT to "text/plain"
|
||||
else -> ShareItem.Kind.FILE to mime
|
||||
}
|
||||
}
|
||||
|
||||
private fun withExtension(name: String, kind: ShareItem.Kind): String {
|
||||
val ext = when (kind) {
|
||||
ShareItem.Kind.PASS -> "pkpass"
|
||||
ShareItem.Kind.PDF -> "pdf"
|
||||
ShareItem.Kind.MAIL -> "eml"
|
||||
ShareItem.Kind.CALENDAR -> "ics"
|
||||
ShareItem.Kind.PAGE -> "html"
|
||||
else -> return name
|
||||
}
|
||||
return if (name.lowercase().endsWith(".$ext")) name else "$name.$ext"
|
||||
}
|
||||
|
||||
private fun displayName(uri: Uri, resolver: ContentResolver): String {
|
||||
runCatching {
|
||||
resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
|
||||
if (it.moveToFirst() && !it.isNull(0)) return it.getString(0)
|
||||
}
|
||||
}
|
||||
return uri.lastPathSegment?.substringAfterLast('/')?.ifBlank { null } ?: "booking"
|
||||
}
|
||||
|
||||
private fun readCapped(input: InputStream): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
val buffer = ByteArray(16 * 1024)
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
if (out.size() > MAX_BYTES) throw ShareLoadException(ShareLoadException.Reason.TOO_LARGE)
|
||||
}
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
private fun checked(item: ShareItem): ShareItem {
|
||||
if (item.data.isEmpty()) throw ShareLoadException(ShareLoadException.Reason.UNSUPPORTED)
|
||||
if (item.data.size > MAX_BYTES) throw ShareLoadException(ShareLoadException.Reason.TOO_LARGE)
|
||||
return item
|
||||
}
|
||||
|
||||
internal fun pageTitle(html: String): String? {
|
||||
val match = Regex("<title[^>]*>(.*?)</title>", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)).find(html) ?: return null
|
||||
return match.groupValues[1].trim().take(120).ifBlank { null }
|
||||
}
|
||||
|
||||
internal fun slug(title: String): String {
|
||||
val words = title.lowercase().split(Regex("[^\\p{L}\\p{N}]+")).filter { it.isNotEmpty() }
|
||||
return words.take(8).joinToString("-").ifBlank { "page" }
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package com.liketrek.trek.share
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.net.Uri
|
||||
import androidx.core.content.pm.ShortcutInfoCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import com.liketrek.trek.MainActivity
|
||||
import com.liketrek.trek.widget.WidgetArt
|
||||
import com.liketrek.trek.widget.WidgetSnapshot
|
||||
import com.liketrek.trek.widget.WidgetStore
|
||||
|
||||
/**
|
||||
* The trips as shortcuts: a long press on TREK's icon lists the coming ones and
|
||||
* opens a trip straight away, and the system share sheet offers them as direct
|
||||
* share targets, so a booking goes into its trip in one tap (ShareActivity
|
||||
* reads the shortcut's id). Rebuilt whenever the app writes its trip list.
|
||||
*/
|
||||
object TrekShortcuts {
|
||||
/** The category res/xml/shortcuts.xml's share-target names. */
|
||||
const val SHARE_CATEGORY = "com.liketrek.trek.category.TRIP_SHARE"
|
||||
private const val MAX = 4
|
||||
|
||||
fun publish(context: Context) {
|
||||
val trips = ShareHandoff.trips(context).take(MAX)
|
||||
val limit = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context).coerceAtLeast(1)
|
||||
val shortcuts = trips.take(limit).mapIndexed { rank, trip ->
|
||||
val open = Intent(Intent.ACTION_VIEW, Uri.parse(WidgetSnapshot.link("/trips/${trip.id}")), context, MainActivity::class.java)
|
||||
ShortcutInfoCompat.Builder(context, "trip.${trip.id}")
|
||||
.setShortLabel(trip.title.take(24))
|
||||
.setLongLabel(trip.title)
|
||||
.setIcon(IconCompat.createWithAdaptiveBitmap(icon(trip)))
|
||||
.setIntent(open)
|
||||
.setCategories(setOf(SHARE_CATEGORY))
|
||||
.setLongLived(true)
|
||||
.setRank(rank)
|
||||
.build()
|
||||
}
|
||||
runCatching {
|
||||
// Trips no longer listed lose their shortcut; pinned ones stay but are disabled by the system.
|
||||
val keep = shortcuts.map { it.id }.toSet()
|
||||
val stale = ShortcutManagerCompat.getDynamicShortcuts(context).map { it.id }.filter { it !in keep }
|
||||
if (stale.isNotEmpty()) ShortcutManagerCompat.removeLongLivedShortcuts(context, stale)
|
||||
ShortcutManagerCompat.setDynamicShortcuts(context, shortcuts)
|
||||
}
|
||||
}
|
||||
|
||||
/** The trip's cover, else its own sky, cropped for an adaptive icon (108dp with a 72dp safe zone). */
|
||||
private fun icon(trip: ShareHandoff.Trip): Bitmap {
|
||||
val size = 216
|
||||
val source = WidgetArt.cover(trip.cover, 432) ?: WidgetArt.sky(trip.title, size, size)
|
||||
val out = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||
val side = minOf(source.width, source.height)
|
||||
val from = Rect((source.width - side) / 2, (source.height - side) / 2, (source.width + side) / 2, (source.height + side) / 2)
|
||||
Canvas(out).drawBitmap(source, from, Rect(0, 0, size, size), Paint(Paint.FILTER_BITMAP_FLAG))
|
||||
return out
|
||||
}
|
||||
|
||||
/** Whether there is anything to publish: the app wrote a trip list. */
|
||||
fun hasTrips(context: Context): Boolean = WidgetStore.trips(context).isNotEmpty()
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
package com.liketrek.trek.tiles
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* The app's own store of Mapbox map resources, the counterpart of the iOS
|
||||
* TrekTileStore (TrekBridgeViewController.swift).
|
||||
*
|
||||
* Android's WebView runs the server's service worker, which keeps every raster
|
||||
* tile the user looked at. Mapbox's vector tiles, styles, sprites and glyphs it
|
||||
* may not keep, but a device cache of what was viewed is allowed for 30 days,
|
||||
* so the WebView client hands those requests here (TrekWebViewClient), and a
|
||||
* Mapbox map keeps working offline for the places already seen. Nothing is
|
||||
* prefetched; the store is pruned to [MAX_BYTES], least recently used first.
|
||||
*/
|
||||
object TrekTileStore {
|
||||
const val MAX_AGE_MS = 30L * 24 * 60 * 60 * 1000
|
||||
const val MAX_BYTES = 250L * 1024 * 1024
|
||||
private const val PRUNE_TARGET = 0.8
|
||||
private const val TIMEOUT_MS = 15_000
|
||||
/** One resource is a tile, a style or a sprite sheet: far below this. */
|
||||
private const val MAX_RESOURCE_BYTES = 8 * 1024 * 1024
|
||||
|
||||
private val HOST = Regex("^(api\\.mapbox\\.com|[a-z0-9-]+\\.tiles\\.mapbox\\.com)$", RegexOption.IGNORE_CASE)
|
||||
/** What a map draws; telemetry, sessions and anything else go straight out. */
|
||||
private val PATHS = listOf("/v4/", "/styles/", "/fonts/", "/models/", "/rasterarrays/", "/raster/")
|
||||
private val DROPPED = setOf("access_token", "sku", "key", "api_key", "apikey")
|
||||
|
||||
@Volatile private var dir: File? = null
|
||||
private var writesSincePrune = 0
|
||||
|
||||
fun init(context: Context) {
|
||||
if (dir == null) dir = File(context.cacheDir, "tiles").apply { mkdirs() }
|
||||
}
|
||||
|
||||
fun handles(request: WebResourceRequest): Boolean = request.method == "GET" && handles(request.url)
|
||||
|
||||
fun handles(url: Uri): Boolean = url.scheme == "https" && HOST.matches(url.host.orEmpty()) && PATHS.any { url.path.orEmpty().startsWith(it) }
|
||||
|
||||
/** The key of a resource: its URL without the credentials and the billing session, which change. */
|
||||
fun key(url: Uri): String {
|
||||
val builder = url.buildUpon().clearQuery()
|
||||
url.queryParameterNames.filter { it.lowercase() !in DROPPED }.sorted().forEach { name ->
|
||||
url.getQueryParameters(name).forEach { builder.appendQueryParameter(name, it) }
|
||||
}
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(builder.build().toString().toByteArray())
|
||||
return digest.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Answers a Mapbox request from the store when it is younger than 30 days,
|
||||
* else from the network, keeping what came back. Null lets the WebView load
|
||||
* it itself (no store, or the network failed).
|
||||
*/
|
||||
fun serve(request: WebResourceRequest): WebResourceResponse? {
|
||||
val root = dir ?: return null
|
||||
val url = request.url
|
||||
val key = key(url)
|
||||
val body = File(root, "$key.bin")
|
||||
val meta = File(root, "$key.type")
|
||||
val now = System.currentTimeMillis()
|
||||
if (body.exists() && now - created(meta) < MAX_AGE_MS) {
|
||||
body.setLastModified(now)
|
||||
val type = runCatching { meta.readLines().getOrNull(1) }.getOrNull() ?: "application/octet-stream"
|
||||
return runCatching { response(body.readBytes(), type) }.getOrNull()
|
||||
}
|
||||
try {
|
||||
val connection = URL(url.toString()).openConnection() as HttpURLConnection
|
||||
connection.connectTimeout = TIMEOUT_MS
|
||||
connection.readTimeout = TIMEOUT_MS
|
||||
// The WebView's own headers go along: Mapbox checks a URL-restricted token
|
||||
// against the Referer, and the server's origin is what the token allows.
|
||||
for ((name, value) in forwardedHeaders(request.requestHeaders)) connection.setRequestProperty(name, value)
|
||||
try {
|
||||
val code = connection.responseCode
|
||||
if (code == 200) {
|
||||
val bytes = connection.inputStream.use { it.readBytes() }
|
||||
if (bytes.size <= MAX_RESOURCE_BYTES) {
|
||||
val type = connection.contentType ?: "application/octet-stream"
|
||||
store(body, meta, bytes, type, now)
|
||||
return response(bytes, type)
|
||||
}
|
||||
}
|
||||
// Anything but a tile (401/403 for a token, 404, a 5xx) the WebView asks for
|
||||
// itself, with everything a browser sends, and the map reports what it gets.
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Offline and never seen: the map shows the gap as it would in a browser.
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Headers that are the request's own; the connection sets the hop-by-hop ones itself. */
|
||||
private val HOP_BY_HOP = setOf("connection", "keep-alive", "transfer-encoding", "te", "upgrade", "host", "content-length", "accept-encoding")
|
||||
|
||||
internal fun forwardedHeaders(headers: Map<String, String>?): Map<String, String> =
|
||||
headers.orEmpty().filterKeys { it.lowercase() !in HOP_BY_HOP }
|
||||
|
||||
/** HttpURLConnection has already undone any gzip, so no Content-Encoding goes along. */
|
||||
private fun response(bytes: ByteArray, type: String): WebResourceResponse {
|
||||
val mime = type.substringBefore(';').trim()
|
||||
val charset = Regex("charset=([^;]+)").find(type)?.groupValues?.get(1)
|
||||
return WebResourceResponse(mime, charset, 200, "OK", cors(), ByteArrayInputStream(bytes))
|
||||
}
|
||||
|
||||
/** The map fetches from the server's origin; the store's answer has to allow it as Mapbox's own does. */
|
||||
private fun cors(): MutableMap<String, String> = mutableMapOf("Access-Control-Allow-Origin" to "*", "Cache-Control" to "no-store")
|
||||
|
||||
private fun created(meta: File): Long = runCatching { meta.readLines().first().toLong() }.getOrDefault(0L)
|
||||
|
||||
@Synchronized
|
||||
private fun store(body: File, meta: File, bytes: ByteArray, type: String, now: Long) {
|
||||
runCatching {
|
||||
body.writeBytes(bytes)
|
||||
meta.writeText("$now\n$type")
|
||||
}
|
||||
if (++writesSincePrune >= 200) {
|
||||
writesSincePrune = 0
|
||||
prune()
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops what is past 30 days, then the least recently used while the store is over its limit. */
|
||||
@Synchronized
|
||||
fun prune() {
|
||||
val root = dir ?: return
|
||||
val now = System.currentTimeMillis()
|
||||
val bodies = root.listFiles { f -> f.name.endsWith(".bin") }?.toMutableList() ?: return
|
||||
bodies.filter { now - created(File(root, it.name.removeSuffix(".bin") + ".type")) > MAX_AGE_MS }.forEach {
|
||||
remove(root, it)
|
||||
bodies.remove(it)
|
||||
}
|
||||
var total = bodies.sumOf { it.length() }
|
||||
if (total <= MAX_BYTES) return
|
||||
val target = (MAX_BYTES * PRUNE_TARGET).toLong()
|
||||
for (file in bodies.sortedBy { it.lastModified() }) {
|
||||
if (total <= target) break
|
||||
total -= file.length()
|
||||
remove(root, file)
|
||||
}
|
||||
}
|
||||
|
||||
private fun remove(root: File, body: File) {
|
||||
body.delete()
|
||||
File(root, body.name.removeSuffix(".bin") + ".type").delete()
|
||||
}
|
||||
|
||||
fun usage(): Long = dir?.listFiles()?.sumOf { it.length() } ?: 0L
|
||||
|
||||
@Synchronized
|
||||
fun clear() {
|
||||
dir?.listFiles()?.forEach { it.delete() }
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
package com.liketrek.trek.tripday
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import com.liketrek.trek.MainActivity
|
||||
import com.liketrek.trek.R
|
||||
import com.liketrek.trek.widget.TrekIcons
|
||||
import com.liketrek.trek.widget.WidgetSnapshot
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
|
||||
/**
|
||||
* The travel day as a Live Update, the Android counterpart of the iOS Live
|
||||
* Activity (TripDayLiveActivity.swift): an ongoing notification with the next
|
||||
* stop, its time as the status-bar chip and a countdown, and the day's stops as
|
||||
* a segmented progress bar. Android 16 promotes it to the top of the shade and
|
||||
* the lock screen (ProgressStyle, POST_PROMOTED_NOTIFICATIONS); older versions
|
||||
* show the same content as a quiet ongoing notification with a plain bar.
|
||||
*
|
||||
* The UI starts, updates and ends it (TrekShell.syncTripDay / endTripDay), and
|
||||
* it lapses on its own at midnight, so a forgotten one never lingers.
|
||||
*/
|
||||
object TripDayNotification {
|
||||
const val CHANNEL = "tripday"
|
||||
private const val ID = 0x7d1
|
||||
|
||||
data class State(
|
||||
val tripTitle: String,
|
||||
val path: String,
|
||||
val dayLabel: String,
|
||||
val nextName: String,
|
||||
val nextTime: String?,
|
||||
/** Minutes after midnight of the next stop, for the countdown. */
|
||||
val nextMinutes: Int?,
|
||||
val done: Int,
|
||||
val total: Int,
|
||||
/** #rrggbb of each stop, for the segments; may be empty. */
|
||||
val colors: List<String?> = emptyList(),
|
||||
)
|
||||
|
||||
fun ensureChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||
if (manager.getNotificationChannel(CHANNEL) != null) return
|
||||
// Default importance: low would keep it out of the status bar chip and promotion.
|
||||
val channel = NotificationChannel(CHANNEL, context.getString(R.string.push_channel_tripday), NotificationManager.IMPORTANCE_DEFAULT).apply {
|
||||
description = context.getString(R.string.push_channel_tripday_description)
|
||||
setSound(null, null)
|
||||
enableVibration(false)
|
||||
setShowBadge(false)
|
||||
}
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
/** Whether it can be shown at all: notifications allowed, and the channel not switched off. */
|
||||
fun allowed(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT >= 33 &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
return false
|
||||
}
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
if (!manager.areNotificationsEnabled()) return false
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = context.getSystemService(NotificationManager::class.java)?.getNotificationChannel(CHANNEL)
|
||||
if (channel != null && channel.importance == NotificationManager.IMPORTANCE_NONE) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Shows the day, or updates the one showing. False when notifications are off. */
|
||||
fun show(context: Context, state: State): Boolean {
|
||||
ensureChannel(context)
|
||||
if (!allowed(context)) return false
|
||||
try {
|
||||
NotificationManagerCompat.from(context).notify(ID, build(context, state))
|
||||
} catch (e: SecurityException) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun end(context: Context) {
|
||||
NotificationManagerCompat.from(context).cancel(ID)
|
||||
}
|
||||
|
||||
fun build(context: Context, state: State, today: LocalDate = LocalDate.now()): android.app.Notification {
|
||||
val open = Intent(Intent.ACTION_VIEW, Uri.parse(WidgetSnapshot.link(state.path)), context, MainActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
val tap = PendingIntent.getActivity(context, ID, open, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
val zone = ZoneId.systemDefault()
|
||||
val midnight = today.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
|
||||
val remaining = (midnight - System.currentTimeMillis()).coerceAtLeast(60_000L)
|
||||
|
||||
val builder = NotificationCompat.Builder(context, CHANNEL)
|
||||
.setSmallIcon(R.drawable.ic_stat_trek)
|
||||
.setContentTitle(state.nextName)
|
||||
.setContentText(listOfNotNull(state.nextTime, state.tripTitle).joinToString(" "))
|
||||
.setSubText(state.dayLabel)
|
||||
.setContentIntent(tap)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setSilent(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setTimeoutAfter(remaining)
|
||||
.setRequestPromotedOngoing(true)
|
||||
|
||||
state.nextTime?.let { builder.setShortCriticalText(it) }
|
||||
state.nextMinutes?.let { minutes ->
|
||||
val at = today.atTime(LocalTime.of((minutes / 60).coerceIn(0, 23), minutes % 60)).atZone(zone).toInstant().toEpochMilli()
|
||||
if (at > System.currentTimeMillis()) {
|
||||
builder.setWhen(at).setShowWhen(true).setUsesChronometer(true).setChronometerCountDown(true)
|
||||
}
|
||||
}
|
||||
|
||||
val total = state.total.coerceAtLeast(1)
|
||||
val done = state.done.coerceIn(0, total)
|
||||
val style = NotificationCompat.ProgressStyle()
|
||||
.setStyledByProgress(true)
|
||||
.setProgressTrackerIcon(IconCompat.createWithBitmap(TrekIcons.bitmap(TrekIcons.vector("figure.walk"), 72, ContextCompat.getColor(context, R.color.notification_accent))))
|
||||
for (i in 0 until total) {
|
||||
val color = state.colors.getOrNull(i)?.let(::parseColor) ?: ContextCompat.getColor(context, R.color.tripday_segment)
|
||||
style.addProgressSegment(NotificationCompat.ProgressStyle.Segment(1).setColor(color))
|
||||
}
|
||||
style.setProgress(done)
|
||||
builder.setStyle(style)
|
||||
// Before Android 16 the style falls back to nothing: the plain bar carries the progress.
|
||||
if (Build.VERSION.SDK_INT < 36) builder.setProgress(total, done, false)
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun parseColor(css: String): Int? = runCatching { android.graphics.Color.parseColor(css) }.getOrNull()
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package com.liketrek.trek.ui
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
/**
|
||||
* The theme of the app's own native screens (the share sheet, the widget's trip
|
||||
* picker): Material You's wallpaper colours on Android 12 and later, TREK's
|
||||
* neutral palette before that, light and dark with the system.
|
||||
*/
|
||||
@Composable
|
||||
fun TrekMaterialTheme(content: @Composable () -> Unit) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val context = LocalContext.current
|
||||
val colors = when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
dark -> darkColorScheme(
|
||||
primary = Color(0xFFE4E4E7),
|
||||
onPrimary = Color(0xFF101013),
|
||||
secondary = Color(0xFF8AAEF0),
|
||||
secondaryContainer = Color(0xFF233250),
|
||||
onSecondaryContainer = Color(0xFFDCE6FA),
|
||||
surface = Color(0xFF0C0C0F),
|
||||
surfaceContainerLow = Color(0xFF16161A),
|
||||
surfaceContainer = Color(0xFF1B1B21),
|
||||
surfaceContainerHigh = Color(0xFF232329),
|
||||
onSurface = Color(0xFFF5F5F7),
|
||||
onSurfaceVariant = Color(0xFF9C9CA3),
|
||||
)
|
||||
else -> lightColorScheme(
|
||||
primary = Color(0xFF111827),
|
||||
onPrimary = Color(0xFFFFFFFF),
|
||||
secondary = Color(0xFF4A7DDB),
|
||||
secondaryContainer = Color(0xFFE4EAF7),
|
||||
onSecondaryContainer = Color(0xFF1B2F57),
|
||||
surface = Color(0xFFFDFCF9),
|
||||
surfaceContainerLow = Color(0xFFF4F2EE),
|
||||
surfaceContainer = Color(0xFFEFEDE8),
|
||||
surfaceContainerHigh = Color(0xFFE9E7E1),
|
||||
onSurface = Color(0xFF101013),
|
||||
onSurfaceVariant = Color(0xFF68686F),
|
||||
)
|
||||
}
|
||||
MaterialTheme(colorScheme = colors, content = content)
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
package com.liketrek.trek.widget
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.AcUnit
|
||||
import androidx.compose.material.icons.rounded.AccountBalance
|
||||
import androidx.compose.material.icons.rounded.Backpack
|
||||
import androidx.compose.material.icons.rounded.BeachAccess
|
||||
import androidx.compose.material.icons.rounded.Bed
|
||||
import androidx.compose.material.icons.rounded.Bolt
|
||||
import androidx.compose.material.icons.rounded.Bookmark
|
||||
import androidx.compose.material.icons.rounded.CalendarMonth
|
||||
import androidx.compose.material.icons.rounded.CameraAlt
|
||||
import androidx.compose.material.icons.rounded.Chat
|
||||
import androidx.compose.material.icons.rounded.CheckCircle
|
||||
import androidx.compose.material.icons.rounded.Checklist
|
||||
import androidx.compose.material.icons.rounded.Cloud
|
||||
import androidx.compose.material.icons.rounded.CreditCard
|
||||
import androidx.compose.material.icons.rounded.DirectionsBoat
|
||||
import androidx.compose.material.icons.rounded.DirectionsBus
|
||||
import androidx.compose.material.icons.rounded.DirectionsCar
|
||||
import androidx.compose.material.icons.rounded.DirectionsRun
|
||||
import androidx.compose.material.icons.rounded.DirectionsWalk
|
||||
import androidx.compose.material.icons.rounded.Event
|
||||
import androidx.compose.material.icons.rounded.Favorite
|
||||
import androidx.compose.material.icons.rounded.FitnessCenter
|
||||
import androidx.compose.material.icons.rounded.Flag
|
||||
import androidx.compose.material.icons.rounded.Flight
|
||||
import androidx.compose.material.icons.rounded.FlightTakeoff
|
||||
import androidx.compose.material.icons.rounded.Dehaze
|
||||
import androidx.compose.material.icons.rounded.Forest
|
||||
import androidx.compose.material.icons.rounded.Grain
|
||||
import androidx.compose.material.icons.rounded.Group
|
||||
import androidx.compose.material.icons.rounded.Hiking
|
||||
import androidx.compose.material.icons.rounded.Home
|
||||
import androidx.compose.material.icons.rounded.Hotel
|
||||
import androidx.compose.material.icons.rounded.Landscape
|
||||
import androidx.compose.material.icons.rounded.LocalBar
|
||||
import androidx.compose.material.icons.rounded.LocalCafe
|
||||
import androidx.compose.material.icons.rounded.LocalHospital
|
||||
import androidx.compose.material.icons.rounded.LocalLibrary
|
||||
import androidx.compose.material.icons.rounded.Luggage
|
||||
import androidx.compose.material.icons.rounded.Map
|
||||
import androidx.compose.material.icons.rounded.MusicNote
|
||||
import androidx.compose.material.icons.rounded.Navigation
|
||||
import androidx.compose.material.icons.rounded.Park
|
||||
import androidx.compose.material.icons.rounded.Place
|
||||
import androidx.compose.material.icons.rounded.Poll
|
||||
import androidx.compose.material.icons.rounded.Public
|
||||
import androidx.compose.material.icons.rounded.Restaurant
|
||||
import androidx.compose.material.icons.rounded.Sailing
|
||||
import androidx.compose.material.icons.rounded.Schedule
|
||||
import androidx.compose.material.icons.rounded.ShoppingBag
|
||||
import androidx.compose.material.icons.rounded.Star
|
||||
import androidx.compose.material.icons.rounded.Storefront
|
||||
import androidx.compose.material.icons.rounded.Thunderstorm
|
||||
import androidx.compose.material.icons.rounded.TheaterComedy
|
||||
import androidx.compose.material.icons.rounded.ConfirmationNumber
|
||||
import androidx.compose.material.icons.rounded.Train
|
||||
import androidx.compose.material.icons.rounded.Water
|
||||
import androidx.compose.material.icons.rounded.WbCloudy
|
||||
import androidx.compose.material.icons.rounded.WbSunny
|
||||
import androidx.compose.material.icons.rounded.Cabin
|
||||
import androidx.compose.ui.graphics.asAndroidPath
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.VectorGroup
|
||||
import androidx.compose.ui.graphics.vector.VectorNode
|
||||
import androidx.compose.ui.graphics.vector.VectorPath
|
||||
import androidx.compose.ui.graphics.vector.PathParser
|
||||
import androidx.glance.ImageProvider
|
||||
|
||||
/**
|
||||
* The widgets' icons: the Material Symbols (rounded) counterparts of the SF
|
||||
* Symbols the snapshot names, so one snapshot draws on both platforms. A widget
|
||||
* is RemoteViews, which cannot draw a Compose vector, so each icon is drawn
|
||||
* once into a small white bitmap and tinted by the widget's theme colour.
|
||||
*/
|
||||
object TrekIcons {
|
||||
/** The SF Symbol names client/src/native/widgetExtras.ts and widgets.ts use, and a few of the widgets' own. */
|
||||
private val SYMBOLS: Map<String, ImageVector> = mapOf(
|
||||
"airplane" to Icons.Rounded.Flight,
|
||||
"airplane.departure" to Icons.Rounded.FlightTakeoff,
|
||||
"tram.fill" to Icons.Rounded.Train,
|
||||
"bus.fill" to Icons.Rounded.DirectionsBus,
|
||||
"car.fill" to Icons.Rounded.DirectionsCar,
|
||||
"ferry.fill" to Icons.Rounded.DirectionsBoat,
|
||||
"sailboat.fill" to Icons.Rounded.Sailing,
|
||||
"bed.double.fill" to Icons.Rounded.Hotel,
|
||||
"house.fill" to Icons.Rounded.Home,
|
||||
"tent.fill" to Icons.Rounded.Cabin,
|
||||
"fork.knife" to Icons.Rounded.Restaurant,
|
||||
"cup.and.saucer.fill" to Icons.Rounded.LocalCafe,
|
||||
"mug.fill" to Icons.Rounded.LocalCafe,
|
||||
"wineglass.fill" to Icons.Rounded.LocalBar,
|
||||
"ticket.fill" to Icons.Rounded.ConfirmationNumber,
|
||||
"theatermasks.fill" to Icons.Rounded.TheaterComedy,
|
||||
"music.note" to Icons.Rounded.MusicNote,
|
||||
"figure.hiking" to Icons.Rounded.Hiking,
|
||||
"figure.walk" to Icons.Rounded.DirectionsWalk,
|
||||
"figure.run" to Icons.Rounded.DirectionsRun,
|
||||
"dumbbell.fill" to Icons.Rounded.FitnessCenter,
|
||||
"bookmark.fill" to Icons.Rounded.Bookmark,
|
||||
"building.columns.fill" to Icons.Rounded.AccountBalance,
|
||||
"building.2.fill" to Icons.Rounded.AccountBalance,
|
||||
"books.vertical.fill" to Icons.Rounded.LocalLibrary,
|
||||
"storefront.fill" to Icons.Rounded.Storefront,
|
||||
"bag.fill" to Icons.Rounded.ShoppingBag,
|
||||
"backpack.fill" to Icons.Rounded.Backpack,
|
||||
"suitcase.rolling.fill" to Icons.Rounded.Luggage,
|
||||
"creditcard.fill" to Icons.Rounded.CreditCard,
|
||||
"cross.case.fill" to Icons.Rounded.LocalHospital,
|
||||
"camera.fill" to Icons.Rounded.CameraAlt,
|
||||
"camera.macro" to Icons.Rounded.Park,
|
||||
"leaf.fill" to Icons.Rounded.Park,
|
||||
"tree.fill" to Icons.Rounded.Forest,
|
||||
"mountain.2.fill" to Icons.Rounded.Landscape,
|
||||
"water.waves" to Icons.Rounded.Water,
|
||||
"beach.umbrella.fill" to Icons.Rounded.BeachAccess,
|
||||
"sun.max.fill" to Icons.Rounded.WbSunny,
|
||||
"bolt.fill" to Icons.Rounded.Bolt,
|
||||
"heart.fill" to Icons.Rounded.Favorite,
|
||||
"star.fill" to Icons.Rounded.Star,
|
||||
"flag.fill" to Icons.Rounded.Flag,
|
||||
"map.fill" to Icons.Rounded.Map,
|
||||
"safari.fill" to Icons.Rounded.Navigation,
|
||||
"location.north.fill" to Icons.Rounded.Navigation,
|
||||
"location.fill" to Icons.Rounded.Place,
|
||||
"globe.europe.africa.fill" to Icons.Rounded.Public,
|
||||
"calendar" to Icons.Rounded.CalendarMonth,
|
||||
"clock" to Icons.Rounded.Schedule,
|
||||
"checklist" to Icons.Rounded.Checklist,
|
||||
"checkmark.circle.fill" to Icons.Rounded.CheckCircle,
|
||||
"person.2.fill" to Icons.Rounded.Group,
|
||||
"bubble.left.fill" to Icons.Rounded.Chat,
|
||||
"chart.bar.fill" to Icons.Rounded.Poll,
|
||||
"event" to Icons.Rounded.Event,
|
||||
)
|
||||
|
||||
/** A booking kind's symbol and colour, the same pairs as TrekStyle.swift. */
|
||||
fun booking(kind: String): Pair<String, Int> = when (kind) {
|
||||
"flight" -> "airplane" to 0xFF4A7DDB.toInt()
|
||||
"train" -> "tram.fill" to 0xFF2FA37A.toInt()
|
||||
"bus" -> "bus.fill" to 0xFF2BA6A6.toInt()
|
||||
"car" -> "car.fill" to 0xFFD6673B.toInt()
|
||||
"cruise", "ferry", "boat" -> "ferry.fill" to 0xFF2E8BC0.toInt()
|
||||
"hotel" -> "bed.double.fill" to 0xFF8E6CD9.toInt()
|
||||
"restaurant" -> "fork.knife" to 0xFFE8A13A.toInt()
|
||||
"event" -> "ticket.fill" to 0xFFD6408B.toInt()
|
||||
"tour" -> "figure.hiking" to 0xFF4E9F3D.toInt()
|
||||
else -> "bookmark.fill" to 0xFF7A7A85.toInt()
|
||||
}
|
||||
|
||||
/** The provider's main weather condition as an icon. */
|
||||
fun weather(main: String): ImageVector = when (main.lowercase()) {
|
||||
"clear" -> Icons.Rounded.WbSunny
|
||||
"clouds" -> Icons.Rounded.WbCloudy
|
||||
"rain", "drizzle" -> Icons.Rounded.Grain
|
||||
"thunderstorm" -> Icons.Rounded.Thunderstorm
|
||||
"snow" -> Icons.Rounded.AcUnit
|
||||
"mist", "fog", "haze" -> Icons.Rounded.Dehaze
|
||||
else -> Icons.Rounded.Cloud
|
||||
}
|
||||
|
||||
fun vector(symbol: String?): ImageVector = symbol?.let { SYMBOLS[it] } ?: Icons.Rounded.Place
|
||||
|
||||
private val cache = HashMap<Pair<ImageVector, Int>, Bitmap>()
|
||||
|
||||
/** The icon as a white bitmap of [sizePx], tinted where it is drawn. */
|
||||
@Synchronized
|
||||
fun bitmap(vector: ImageVector, sizePx: Int = 48): Bitmap = cache.getOrPut(vector to sizePx) { render(vector, sizePx) }
|
||||
|
||||
fun provider(symbol: String?, sizePx: Int = 48): ImageProvider = ImageProvider(bitmap(vector(symbol), sizePx))
|
||||
|
||||
fun provider(vector: ImageVector, sizePx: Int = 48): ImageProvider = ImageProvider(bitmap(vector, sizePx))
|
||||
|
||||
/** The icon in [color], for places that draw it as it is (a notification's progress tracker). */
|
||||
fun bitmap(vector: ImageVector, sizePx: Int, color: Int): Bitmap = render(vector, sizePx, color)
|
||||
|
||||
private fun render(vector: ImageVector, sizePx: Int, color: Int = android.graphics.Color.WHITE): Bitmap {
|
||||
val bitmap = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bitmap)
|
||||
val scale = Matrix().apply { setScale(sizePx / vector.viewportWidth, sizePx / vector.viewportHeight) }
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
this.color = color
|
||||
style = Paint.Style.FILL
|
||||
}
|
||||
fun draw(node: VectorNode) {
|
||||
when (node) {
|
||||
is VectorGroup -> node.forEach { draw(it) }
|
||||
is VectorPath -> {
|
||||
val path: Path = PathParser().addPathNodes(node.pathData).toPath().asAndroidPath()
|
||||
path.fillType = if (node.pathFillType == androidx.compose.ui.graphics.PathFillType.EvenOdd) {
|
||||
Path.FillType.EVEN_ODD
|
||||
} else {
|
||||
Path.FillType.WINDING
|
||||
}
|
||||
path.transform(scale)
|
||||
canvas.drawPath(path, paint)
|
||||
}
|
||||
}
|
||||
}
|
||||
draw(vector.root)
|
||||
return bitmap
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
package com.liketrek.trek.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import kotlin.reflect.KClass
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** The widget providers the manifest lists, one per widget in the picker. */
|
||||
abstract class TrekWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
|
||||
super.onDeleted(context, appWidgetIds)
|
||||
WidgetStore.forget(context, appWidgetIds)
|
||||
}
|
||||
}
|
||||
|
||||
class TripWidgetReceiver : TrekWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = TripWidget()
|
||||
}
|
||||
|
||||
class TodayWidgetReceiver : TrekWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = TodayWidget()
|
||||
}
|
||||
|
||||
class BookingsWidgetReceiver : TrekWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = BookingsWidget()
|
||||
}
|
||||
|
||||
class TodosWidgetReceiver : TrekWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = TodosWidget()
|
||||
}
|
||||
|
||||
class PackingWidgetReceiver : TrekWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = PackingWidget()
|
||||
}
|
||||
|
||||
class AtlasWidgetReceiver : TrekWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = AtlasWidget()
|
||||
}
|
||||
|
||||
class CollabWidgetReceiver : TrekWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = CollabWidget()
|
||||
}
|
||||
|
||||
/** Redraws every placed TREK widget after the app wrote a new snapshot. */
|
||||
object TrekWidgetUpdater {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
val receivers: List<KClass<out GlanceAppWidgetReceiver>> = listOf(
|
||||
TripWidgetReceiver::class,
|
||||
TodayWidgetReceiver::class,
|
||||
BookingsWidgetReceiver::class,
|
||||
TodosWidgetReceiver::class,
|
||||
PackingWidgetReceiver::class,
|
||||
AtlasWidgetReceiver::class,
|
||||
CollabWidgetReceiver::class,
|
||||
)
|
||||
|
||||
/** The widget a placed widget id belongs to, by its provider. */
|
||||
fun widgetFor(context: Context, appWidgetId: Int): GlanceAppWidget? {
|
||||
val provider = android.appwidget.AppWidgetManager.getInstance(context).getAppWidgetInfo(appWidgetId)?.provider?.className
|
||||
?: return null
|
||||
val receiver = receivers.firstOrNull { it.java.name == provider } ?: return null
|
||||
return receiver.java.getDeclaredConstructor().newInstance().glanceAppWidget
|
||||
}
|
||||
|
||||
fun widgets(): List<GlanceAppWidget> =
|
||||
listOf(TripWidget(), TodayWidget(), BookingsWidget(), TodosWidget(), PackingWidget(), AtlasWidget(), CollabWidget())
|
||||
|
||||
@JvmStatic
|
||||
fun refreshAll(context: Context) {
|
||||
val app = context.applicationContext
|
||||
scope.launch { for (widget in widgets()) runCatching { widget.updateAll(app) } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Android 15's widget picker shows a live preview drawn by the widget itself
|
||||
* (providePreview) instead of a static image. The system rate-limits the
|
||||
* call, so it runs once per app version.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun publishPreviews(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < 35) return
|
||||
val app = context.applicationContext
|
||||
val prefs = app.getSharedPreferences("trek.widgets", Context.MODE_PRIVATE)
|
||||
val version = runCatching { app.packageManager.getPackageInfo(app.packageName, 0).lastUpdateTime }.getOrDefault(0L)
|
||||
if (prefs.getLong("previews", -1L) == version) return
|
||||
scope.launch {
|
||||
val manager = GlanceAppWidgetManager(app)
|
||||
val all = receivers.all { runCatching { manager.setWidgetPreviews(it); true }.getOrDefault(false) }
|
||||
if (all) prefs.edit().putLong("previews", version).apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,815 +0,0 @@
|
||||
package com.liketrek.trek.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.CalendarMonth
|
||||
import androidx.compose.material.icons.rounded.CheckCircle
|
||||
import androidx.compose.material.icons.rounded.Flight
|
||||
import androidx.compose.material.icons.rounded.Group
|
||||
import androidx.compose.material.icons.rounded.Luggage
|
||||
import androidx.compose.material.icons.rounded.Place
|
||||
import androidx.compose.material.icons.rounded.Public
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.glance.ColorFilter
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.Image
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.LocalSize
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.background
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.ColumnScope
|
||||
import androidx.glance.layout.ContentScale
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.fillMaxHeight
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.height
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.layout.size
|
||||
import androidx.glance.layout.width
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextAlign
|
||||
import androidx.glance.text.TextStyle
|
||||
import androidx.glance.unit.ColorProvider
|
||||
import java.time.LocalDate
|
||||
|
||||
/** The sizes every TREK widget lays itself out for (a 2x2, a 4x2 and a 4x4 cell block). */
|
||||
object TrekSizes {
|
||||
val SMALL = DpSize(120.dp, 120.dp)
|
||||
val WIDE = DpSize(250.dp, 120.dp)
|
||||
val LARGE = DpSize(250.dp, 260.dp)
|
||||
val all = setOf(SMALL, WIDE, LARGE)
|
||||
}
|
||||
|
||||
private val DpSize.isWide get() = width >= 220.dp
|
||||
private val DpSize.isTall get() = height >= 220.dp
|
||||
|
||||
/**
|
||||
* A TREK widget: reads the snapshot of the trip it is set to (WidgetStore) and
|
||||
* draws [Content]. The app writes the snapshot and refreshes the widgets
|
||||
* (TrekWidgetUpdater); [preview] draws the same views from a sample trip for
|
||||
* the widget picker and the screenshot tests.
|
||||
*/
|
||||
abstract class TrekWidget : GlanceAppWidget() {
|
||||
override val sizeMode: SizeMode = SizeMode.Responsive(TrekSizes.all)
|
||||
override val previewSizeMode = SizeMode.Responsive(TrekSizes.all)
|
||||
|
||||
/** Whether the widget shows one trip, so the trip picker applies to it. */
|
||||
open val perTrip: Boolean = true
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val appWidgetId = runCatching { GlanceAppWidgetManager(context).getAppWidgetId(id) }.getOrDefault(-1)
|
||||
provideContent {
|
||||
// Read inside the composition: a session outlives one update, and every
|
||||
// write (the dashboard's, then each trip's) must reach it.
|
||||
val version by WidgetStore.version.collectAsState()
|
||||
val snapshot = remember(version) {
|
||||
val tripId = if (perTrip && appWidgetId >= 0) WidgetStore.tripFor(context, appWidgetId) else WidgetStore.AUTO
|
||||
WidgetStore.load(context, tripId)
|
||||
}
|
||||
TrekWidgetTheme.Themed { Content(snapshot) }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun providePreview(context: Context, widgetCategory: Int) {
|
||||
provideContent { TrekWidgetTheme.Themed { Content(WidgetSamples.snapshot(running = false)) } }
|
||||
}
|
||||
|
||||
@Composable
|
||||
abstract fun Content(snapshot: WidgetSnapshot?)
|
||||
}
|
||||
|
||||
// ---- Trip ----
|
||||
|
||||
/** The trip: a countdown on its own cover before it starts, the day and what comes next while it runs. */
|
||||
class TripWidget : TrekWidget() {
|
||||
@Composable
|
||||
override fun Content(snapshot: WidgetSnapshot?) {
|
||||
val context = LocalContext.current
|
||||
val trip = snapshot?.trip
|
||||
if (snapshot == null || trip == null) {
|
||||
EmptyWidget(snapshot?.labels?.noTrip ?: WidgetSamples.emptyText(context))
|
||||
return
|
||||
}
|
||||
val size = LocalSize.current
|
||||
WidgetFrame(onClick = openPath(context, trip.path), background = TrekStatus.scrim, padding = 0.dp) {
|
||||
Box(modifier = GlanceModifier.fillMaxSize()) {
|
||||
Image(
|
||||
provider = ImageProvider(WidgetArt.cover(trip.cover) ?: WidgetArt.sky(trip.title)),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = GlanceModifier.fillMaxSize(),
|
||||
)
|
||||
Image(
|
||||
provider = ImageProvider(com.liketrek.trek.R.drawable.widget_scrim),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.FillBounds,
|
||||
modifier = GlanceModifier.fillMaxSize(),
|
||||
)
|
||||
Column(modifier = GlanceModifier.fillMaxSize().padding(14.dp)) {
|
||||
val today = snapshot.today
|
||||
if (today != null) Running(snapshot, trip, today, size) else Upcoming(snapshot, trip, size)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.Upcoming(snapshot: WidgetSnapshot, trip: WidgetSnapshot.Trip, size: DpSize) {
|
||||
val context = LocalContext.current
|
||||
val labels = snapshot.labels
|
||||
val days = (snapshot.daysUntilStart() ?: 0L).coerceAtLeast(0L)
|
||||
if (!size.isWide) {
|
||||
// Small: the number carries it, with the trip's name under it.
|
||||
Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Rounded.Flight, TrekStatus.white, 16.dp)
|
||||
Spacer(GlanceModifier.defaultWeight())
|
||||
snapshot.weather?.let { WeatherChip(it, onPhoto = true) }
|
||||
}
|
||||
Spacer(GlanceModifier.defaultWeight())
|
||||
Countdown(days, labels, ring = false)
|
||||
Line(trip.title, 14.sp, TrekStatus.white, FontWeight.Bold, maxLines = 1)
|
||||
return
|
||||
}
|
||||
Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = GlanceModifier.defaultWeight()) {
|
||||
Eyebrow(labels.nextTrip, TrekStatus.whiteMuted)
|
||||
Spacer(GlanceModifier.height(2.dp))
|
||||
Line(trip.title, if (size.isTall) 22.sp else 18.sp, TrekStatus.white, FontWeight.Bold, maxLines = 2)
|
||||
trip.place?.let {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Rounded.Place, TrekStatus.whiteMuted, 12.dp)
|
||||
Spacer(GlanceModifier.width(3.dp))
|
||||
Line(it, 12.sp, TrekStatus.whiteMuted, FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
if (size.isTall) {
|
||||
RelativeDay.range(context, trip.start, trip.end)?.let {
|
||||
Spacer(GlanceModifier.height(6.dp))
|
||||
Chip(it, TrekStatus.glass, TrekStatus.white, TrekIcons.provider(Icons.Rounded.CalendarMonth), TrekStatus.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(GlanceModifier.width(10.dp))
|
||||
Countdown(days, labels, ring = true)
|
||||
}
|
||||
if (size.isTall) {
|
||||
Spacer(GlanceModifier.defaultWeight())
|
||||
TripFacts(snapshot, onPhoto = true)
|
||||
snapshot.bookings.firstOrNull()?.let {
|
||||
Spacer(GlanceModifier.height(8.dp))
|
||||
BookingRow(it, labels, onPhoto = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The days to go: a big number, or today and tomorrow in words; around a ring of the last 60 days on the wide sizes. */
|
||||
@Composable
|
||||
private fun Countdown(days: Long, labels: WidgetSnapshot.Labels, ring: Boolean) {
|
||||
val number: @Composable () -> Unit = {
|
||||
when (days) {
|
||||
0L -> Line(labels.todayShort, 26.sp, TrekStatus.white, FontWeight.Bold)
|
||||
1L -> Line(labels.tomorrow, 20.sp, TrekStatus.white, FontWeight.Bold)
|
||||
else -> if (ring) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = days.toString(),
|
||||
style = TextStyle(color = TrekStatus.white, fontSize = if (days > 99) 22.sp else 28.sp, fontWeight = FontWeight.Bold),
|
||||
maxLines = 1,
|
||||
)
|
||||
Eyebrow(labels.days, TrekStatus.whiteMuted)
|
||||
}
|
||||
} else {
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
Text(days.toString(), style = TextStyle(color = TrekStatus.white, fontSize = 34.sp, fontWeight = FontWeight.Bold), maxLines = 1)
|
||||
Spacer(GlanceModifier.width(4.dp))
|
||||
Line(labels.days, 13.sp, TrekStatus.whiteMuted, FontWeight.Bold, modifier = GlanceModifier.padding(bottom = 6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!ring) {
|
||||
number()
|
||||
return
|
||||
}
|
||||
val progress = 1f - (days.coerceAtMost(60L).toFloat() / 60f)
|
||||
Box(modifier = GlanceModifier.size(84.dp), contentAlignment = Alignment.Center) {
|
||||
Image(
|
||||
provider = ImageProvider(WidgetArt.ring(progress)),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(TrekStatus.white),
|
||||
modifier = GlanceModifier.fillMaxSize(),
|
||||
)
|
||||
number()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.Running(snapshot: WidgetSnapshot, trip: WidgetSnapshot.Trip, today: WidgetSnapshot.Today, size: DpSize) {
|
||||
val labels = snapshot.labels
|
||||
val now = nowMinutes()
|
||||
val next = today.stops.firstOrNull { (it.minutes ?: -1) >= now } ?: today.stops.lastOrNull()
|
||||
val index = today.index ?: 1
|
||||
val total = today.total ?: snapshot.tripLength ?: 1
|
||||
Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Eyebrow(if (size.isWide) trip.title else today.dayLabel, TrekStatus.whiteMuted, GlanceModifier.defaultWeight())
|
||||
snapshot.weather?.let { WeatherChip(it, onPhoto = true) }
|
||||
}
|
||||
if (size.isWide) Line(today.dayLabel, 20.sp, TrekStatus.white, FontWeight.Bold, modifier = GlanceModifier.padding(top = 2.dp))
|
||||
Spacer(GlanceModifier.height(8.dp))
|
||||
DaySegments(index, total, TrekStatus.white)
|
||||
Spacer(GlanceModifier.defaultWeight())
|
||||
if (size.isWide) {
|
||||
Column(modifier = GlanceModifier.fillMaxWidth().background(TrekStatus.glass).cornerRadius(16.dp).padding(10.dp)) {
|
||||
StopList(today.stops, now, limit = if (size.isTall) 5 else 1, onPhoto = true, nothing = labels.nothingPlanned, nowLabel = labels.now)
|
||||
}
|
||||
} else if (next != null) {
|
||||
Line(next.time ?: labels.today, 26.sp, TrekStatus.white, FontWeight.Bold)
|
||||
Line(next.name, 14.sp, TrekStatus.white, FontWeight.Bold, maxLines = 2)
|
||||
} else {
|
||||
Line(labels.nothingPlanned, 14.sp, TrekStatus.white, FontWeight.Bold, maxLines = 3)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The trip's numbers: days, places, travellers. */
|
||||
@Composable
|
||||
private fun TripFacts(snapshot: WidgetSnapshot, onPhoto: Boolean) {
|
||||
val labels = snapshot.labels
|
||||
val facts = listOfNotNull(
|
||||
snapshot.tripLength?.let { it to labels.days },
|
||||
snapshot.trip?.placeCount?.let { it to labels.places },
|
||||
snapshot.trip?.memberCount?.let { it to labels.travellers },
|
||||
)
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
facts.forEachIndexed { i, (value, label) ->
|
||||
Column(
|
||||
modifier = GlanceModifier.defaultWeight().background(if (onPhoto) TrekStatus.glass else GlanceTheme.colors.secondaryContainer)
|
||||
.cornerRadius(14.dp).padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
) {
|
||||
Line(value.toString(), 18.sp, if (onPhoto) TrekStatus.white else GlanceTheme.colors.onSecondaryContainer, FontWeight.Bold)
|
||||
Line(label, 11.sp, if (onPhoto) TrekStatus.whiteMuted else GlanceTheme.colors.onSecondaryContainer)
|
||||
}
|
||||
if (i < facts.size - 1) Spacer(GlanceModifier.width(6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Today ----
|
||||
|
||||
/** Today's plan as a timeline, with the weather and what is next. */
|
||||
class TodayWidget : TrekWidget() {
|
||||
@Composable
|
||||
override fun Content(snapshot: WidgetSnapshot?) {
|
||||
val context = LocalContext.current
|
||||
val size = LocalSize.current
|
||||
val labels = snapshot?.labels
|
||||
val today = snapshot?.today
|
||||
val trip = snapshot?.trip
|
||||
if (snapshot == null || labels == null || trip == null) {
|
||||
EmptyWidget(labels?.noTrip ?: WidgetSamples.emptyText(context))
|
||||
return
|
||||
}
|
||||
WidgetFrame(onClick = openPath(context, trip.path)) {
|
||||
Column(modifier = GlanceModifier.fillMaxSize()) {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = GlanceModifier.defaultWeight()) {
|
||||
if (size.isTall) Eyebrow(if (today != null) labels.today else labels.nextTrip, GlanceTheme.colors.primary)
|
||||
Line(today?.dayLabel ?: trip.title, if (size.isWide) 17.sp else 14.sp, weight = FontWeight.Bold)
|
||||
}
|
||||
snapshot.weather?.let { WeatherChip(it, onPhoto = false) }
|
||||
}
|
||||
Spacer(GlanceModifier.height(if (size.isTall) 10.dp else 6.dp))
|
||||
if (today == null || today.stops.isEmpty()) {
|
||||
Spacer(GlanceModifier.defaultWeight())
|
||||
val days = snapshot.daysUntilStart()
|
||||
val text = if (today == null && days != null && days > 0) labels.inDays.replace("{n}", days.toString()) else labels.nothingPlanned
|
||||
Line(text, 14.sp, GlanceTheme.colors.onSurfaceVariant, FontWeight.Medium, maxLines = 3)
|
||||
Spacer(GlanceModifier.defaultWeight())
|
||||
} else {
|
||||
val limit = when {
|
||||
size.isTall -> 6
|
||||
size.isWide -> 2
|
||||
else -> 1
|
||||
}
|
||||
StopList(today.stops, nowMinutes(), limit, onPhoto = false, nothing = labels.nothingPlanned, nowLabel = labels.now, compact = !size.isWide)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The stops from the next one on, at most [limit], the next one marked. */
|
||||
@Composable
|
||||
fun StopList(
|
||||
stops: List<WidgetSnapshot.Stop>,
|
||||
nowMinutes: Int,
|
||||
limit: Int,
|
||||
onPhoto: Boolean,
|
||||
nothing: String,
|
||||
nowLabel: String,
|
||||
compact: Boolean = false,
|
||||
) {
|
||||
if (stops.isEmpty()) {
|
||||
Line(nothing, 13.sp, if (onPhoto) TrekStatus.white else GlanceTheme.colors.onSurfaceVariant)
|
||||
return
|
||||
}
|
||||
val nextIndex = stops.indexOfFirst { (it.minutes ?: -1) >= nowMinutes }.let { if (it < 0) stops.size - 1 else it }
|
||||
// Keep the last done stop in view when there is room, so the day reads as a line.
|
||||
val from = (nextIndex - if (limit > 3) 1 else 0).coerceIn(0, (stops.size - limit).coerceAtLeast(0))
|
||||
val shown = stops.drop(from).take(limit)
|
||||
shown.forEachIndexed { i, stop ->
|
||||
val index = from + i
|
||||
val isNext = index == nextIndex
|
||||
val done = index < nextIndex
|
||||
val ink = when {
|
||||
onPhoto -> if (done) TrekStatus.whiteFaint else TrekStatus.white
|
||||
done -> GlanceTheme.colors.onSurfaceVariant
|
||||
else -> GlanceTheme.colors.onSurface
|
||||
}
|
||||
Row(modifier = GlanceModifier.fillMaxWidth().padding(vertical = 3.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
val accent = cssColor(stop.color) ?: if (onPhoto) TrekStatus.white else GlanceTheme.colors.primary
|
||||
if (compact) {
|
||||
Box(modifier = GlanceModifier.size(8.dp).cornerRadius(4.dp).background(accent)) {}
|
||||
} else {
|
||||
IconBadge(
|
||||
stop.symbol,
|
||||
tint = if (onPhoto) TrekStatus.white else GlanceTheme.colors.onSecondaryContainer,
|
||||
background = if (onPhoto) TrekStatus.glass else GlanceTheme.colors.secondaryContainer,
|
||||
size = 26.dp,
|
||||
)
|
||||
}
|
||||
Spacer(GlanceModifier.width(8.dp))
|
||||
Column(modifier = GlanceModifier.defaultWeight()) {
|
||||
Line(stop.name, 13.sp, ink, if (isNext) FontWeight.Bold else FontWeight.Medium)
|
||||
if (compact && stop.time != null) Line(stop.time, 11.sp, if (onPhoto) TrekStatus.whiteMuted else GlanceTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
if (!compact) {
|
||||
if (isNext && !onPhoto) {
|
||||
Chip(stop.time ?: nowLabel, GlanceTheme.colors.primary, GlanceTheme.colors.onPrimary)
|
||||
} else {
|
||||
Line(stop.time ?: "", 12.sp, ink, if (isNext) FontWeight.Bold else FontWeight.Normal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Bookings ----
|
||||
|
||||
/** The next flights, trains, hotels and tables, and when they are due. */
|
||||
class BookingsWidget : TrekWidget() {
|
||||
@Composable
|
||||
override fun Content(snapshot: WidgetSnapshot?) {
|
||||
val context = LocalContext.current
|
||||
val size = LocalSize.current
|
||||
val labels = snapshot?.labels ?: return EmptyWidget(WidgetSamples.emptyText(context))
|
||||
val bookings = snapshot.bookings
|
||||
WidgetFrame(onClick = openPath(context, bookings.firstOrNull()?.path ?: snapshot.trip?.path ?: "/")) {
|
||||
Column(modifier = GlanceModifier.fillMaxSize()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Rounded.Flight, GlanceTheme.colors.primary, 14.dp)
|
||||
Spacer(GlanceModifier.width(5.dp))
|
||||
Eyebrow(labels.bookings, GlanceTheme.colors.primary)
|
||||
}
|
||||
Spacer(GlanceModifier.height(8.dp))
|
||||
if (bookings.isEmpty()) {
|
||||
Spacer(GlanceModifier.defaultWeight())
|
||||
Line(labels.noBookings, 14.sp, GlanceTheme.colors.onSurfaceVariant, FontWeight.Medium, maxLines = 2)
|
||||
Spacer(GlanceModifier.defaultWeight())
|
||||
} else if (!size.isWide) {
|
||||
val first = bookings.first()
|
||||
val (symbol, color) = TrekIcons.booking(first.kind)
|
||||
IconBadge(symbol, TrekStatus.white, ColorProvider(androidx.compose.ui.graphics.Color(color)), 30.dp)
|
||||
Spacer(GlanceModifier.defaultWeight())
|
||||
Line(first.title, 14.sp, weight = FontWeight.Bold, maxLines = 1)
|
||||
Line(
|
||||
listOfNotNull(RelativeDay.label(first.date, labels), first.time).joinToString(", "),
|
||||
12.sp,
|
||||
GlanceTheme.colors.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
val limit = if (size.isTall) 4 else 1
|
||||
bookings.take(limit).forEach { booking ->
|
||||
BookingRow(booking, labels, onPhoto = false)
|
||||
Spacer(GlanceModifier.height(5.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BookingRow(booking: WidgetSnapshot.Booking, labels: WidgetSnapshot.Labels, onPhoto: Boolean) {
|
||||
val context = LocalContext.current
|
||||
val (symbol, color) = TrekIcons.booking(booking.kind)
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth()
|
||||
.background(if (onPhoto) TrekStatus.glass else GlanceTheme.colors.surfaceVariant)
|
||||
.cornerRadius(14.dp).padding(horizontal = 8.dp, vertical = 6.dp)
|
||||
.clickable(openPath(context, booking.path)),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconBadge(symbol, TrekStatus.white, ColorProvider(androidx.compose.ui.graphics.Color(color)), 30.dp)
|
||||
Spacer(GlanceModifier.width(10.dp))
|
||||
Column(modifier = GlanceModifier.defaultWeight()) {
|
||||
Line(booking.title, 13.sp, if (onPhoto) TrekStatus.white else GlanceTheme.colors.onSurface, FontWeight.Bold)
|
||||
booking.detail?.let { Line(it, 11.sp, if (onPhoto) TrekStatus.whiteMuted else GlanceTheme.colors.onSurfaceVariant) }
|
||||
}
|
||||
Spacer(GlanceModifier.width(6.dp))
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
RelativeDay.label(booking.date, labels)?.let { Line(it, 12.sp, if (onPhoto) TrekStatus.white else GlanceTheme.colors.onSurface, FontWeight.Bold) }
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(modifier = GlanceModifier.size(6.dp).cornerRadius(3.dp).background(if (booking.confirmed) TrekStatus.confirmed else TrekStatus.pending)) {}
|
||||
Spacer(GlanceModifier.width(4.dp))
|
||||
Line(booking.time ?: if (booking.confirmed) labels.confirmed else labels.pending, 11.sp, if (onPhoto) TrekStatus.whiteMuted else GlanceTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- To-dos ----
|
||||
|
||||
/** What is still open for the trip, and how far the packing has come. */
|
||||
class TodosWidget : TrekWidget() {
|
||||
@Composable
|
||||
override fun Content(snapshot: WidgetSnapshot?) {
|
||||
val context = LocalContext.current
|
||||
val size = LocalSize.current
|
||||
val labels = snapshot?.labels ?: return EmptyWidget(WidgetSamples.emptyText(context))
|
||||
val todos = snapshot.todos
|
||||
WidgetFrame(onClick = openPath(context, todos?.path ?: snapshot.trip?.path ?: "/")) {
|
||||
Column(modifier = GlanceModifier.fillMaxSize()) {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Rounded.CheckCircle, GlanceTheme.colors.primary, 14.dp)
|
||||
Spacer(GlanceModifier.width(5.dp))
|
||||
Eyebrow(labels.todos, GlanceTheme.colors.primary, GlanceModifier.defaultWeight())
|
||||
if (todos != null && todos.total > 0) Line("${todos.total - todos.open}/${todos.total}", 12.sp, GlanceTheme.colors.onSurfaceVariant, FontWeight.Bold)
|
||||
}
|
||||
Spacer(GlanceModifier.height(8.dp))
|
||||
if (todos == null || todos.open == 0) {
|
||||
Spacer(GlanceModifier.defaultWeight())
|
||||
Line(labels.noTodos, 16.sp, weight = FontWeight.Bold)
|
||||
snapshot.trip?.title?.let { Line(it, 12.sp, GlanceTheme.colors.onSurfaceVariant) }
|
||||
Spacer(GlanceModifier.defaultWeight())
|
||||
} else {
|
||||
if (!size.isWide) {
|
||||
Line(todos.open.toString(), 34.sp, GlanceTheme.colors.primary, FontWeight.Bold)
|
||||
Spacer(GlanceModifier.defaultWeight())
|
||||
}
|
||||
val limit = when {
|
||||
size.isTall -> 6
|
||||
size.isWide -> 3
|
||||
else -> 1
|
||||
}
|
||||
Column(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
todos.items.take(limit).forEach { todo ->
|
||||
Row(modifier = GlanceModifier.fillMaxWidth().padding(vertical = 3.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = GlanceModifier.size(14.dp).cornerRadius(7.dp).background(priorityColor(todo.priority) ?: GlanceTheme.colors.outline),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(modifier = GlanceModifier.size(10.dp).cornerRadius(5.dp).background(GlanceTheme.colors.widgetBackground)) {}
|
||||
}
|
||||
Spacer(GlanceModifier.width(8.dp))
|
||||
Line(todo.name, 13.sp, modifier = GlanceModifier.defaultWeight())
|
||||
if (size.isWide) RelativeDay.label(todo.due, labels)?.let { Line(it, 11.sp, GlanceTheme.colors.onSurfaceVariant, FontWeight.Medium) }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (size.isWide) Spacer(GlanceModifier.defaultWeight())
|
||||
}
|
||||
val packing = snapshot.packing
|
||||
if (size.isTall && packing != null && packing.total > 0) {
|
||||
Spacer(GlanceModifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Rounded.Luggage, GlanceTheme.colors.onSurfaceVariant, 13.dp)
|
||||
Spacer(GlanceModifier.width(5.dp))
|
||||
Line("${labels.packing}: ${packing.packed}/${packing.total}", 12.sp, GlanceTheme.colors.onSurfaceVariant, FontWeight.Medium)
|
||||
}
|
||||
Spacer(GlanceModifier.height(5.dp))
|
||||
Bar(packing.packed.toFloat() / packing.total, GlanceTheme.colors.primary, GlanceTheme.colors.surfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun priorityColor(priority: Int): ColorProvider? = when (priority) {
|
||||
3 -> TrekStatus.danger
|
||||
2 -> TrekStatus.pending
|
||||
1 -> ColorProvider(androidx.compose.ui.graphics.Color(0xFF4A7DDB))
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Packing ----
|
||||
|
||||
/** How full the bags are, their weight, and what is still missing. */
|
||||
class PackingWidget : TrekWidget() {
|
||||
@Composable
|
||||
override fun Content(snapshot: WidgetSnapshot?) {
|
||||
val context = LocalContext.current
|
||||
val size = LocalSize.current
|
||||
val labels = snapshot?.labels ?: return EmptyWidget(WidgetSamples.emptyText(context))
|
||||
val packing = snapshot.packing
|
||||
WidgetFrame(onClick = openPath(context, packing?.path ?: snapshot.trip?.path ?: "/")) {
|
||||
Column(modifier = GlanceModifier.fillMaxSize()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Rounded.Luggage, GlanceTheme.colors.primary, 14.dp)
|
||||
Spacer(GlanceModifier.width(5.dp))
|
||||
Eyebrow(labels.packing, GlanceTheme.colors.primary)
|
||||
}
|
||||
if (packing == null || packing.total == 0) {
|
||||
Spacer(GlanceModifier.defaultWeight())
|
||||
Line(snapshot.trip?.title ?: labels.noTrip, 14.sp, GlanceTheme.colors.onSurfaceVariant, FontWeight.Medium, maxLines = 3)
|
||||
Spacer(GlanceModifier.defaultWeight())
|
||||
return@Column
|
||||
}
|
||||
val progress = packing.packed.toFloat() / packing.total
|
||||
Spacer(GlanceModifier.height(if (size.isWide) 8.dp else 4.dp))
|
||||
if (!size.isWide) {
|
||||
// Small: the ring is the widget.
|
||||
Box(modifier = GlanceModifier.fillMaxWidth().defaultWeight(), contentAlignment = Alignment.Center) {
|
||||
PackRing(progress, 64.dp)
|
||||
}
|
||||
Line("${packing.packed}/${packing.total} ${labels.packed}", 11.sp, GlanceTheme.colors.onSurfaceVariant, FontWeight.Medium)
|
||||
return@Column
|
||||
}
|
||||
Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
PackRing(progress, 64.dp)
|
||||
Spacer(GlanceModifier.width(12.dp))
|
||||
Column(modifier = GlanceModifier.defaultWeight()) {
|
||||
Line("${packing.packed}/${packing.total}", 18.sp, weight = FontWeight.Bold)
|
||||
Line(labels.packed, 12.sp, GlanceTheme.colors.onSurfaceVariant)
|
||||
if (!size.isTall && packing.missing.isNotEmpty()) {
|
||||
Line("${packing.missing.size} ${labels.missing}", 12.sp, GlanceTheme.colors.primary, FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (size.isTall) {
|
||||
Spacer(GlanceModifier.height(10.dp))
|
||||
// Each group in a column of its own: a Glance column takes ten children at most.
|
||||
Column(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
packing.bags.take(2).forEach { bag -> BagRow(bag) }
|
||||
}
|
||||
if (packing.missing.isNotEmpty()) {
|
||||
Spacer(GlanceModifier.height(4.dp))
|
||||
Column(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
Eyebrow(labels.missing)
|
||||
Spacer(GlanceModifier.height(2.dp))
|
||||
packing.missing.take(2).forEach { item ->
|
||||
Row(modifier = GlanceModifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(modifier = GlanceModifier.size(6.dp).cornerRadius(3.dp).background(cssColor(item.color) ?: GlanceTheme.colors.outline)) {}
|
||||
Spacer(GlanceModifier.width(8.dp))
|
||||
Line(item.name, 12.sp, modifier = GlanceModifier.defaultWeight())
|
||||
item.category?.let { Line(it, 11.sp, GlanceTheme.colors.onSurfaceVariant) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PackRing(progress: Float, size: androidx.compose.ui.unit.Dp) {
|
||||
Box(modifier = GlanceModifier.size(size), contentAlignment = Alignment.Center) {
|
||||
Image(
|
||||
provider = ImageProvider(WidgetArt.ring(progress, stroke = 16f)),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(GlanceTheme.colors.primary),
|
||||
modifier = GlanceModifier.fillMaxSize(),
|
||||
)
|
||||
Line("${(progress * 100).toInt()}%", 13.sp, weight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BagRow(bag: WidgetSnapshot.Bag) {
|
||||
val bagColor = cssColor(bag.color) ?: GlanceTheme.colors.primary
|
||||
Column(modifier = GlanceModifier.fillMaxWidth().padding(bottom = 6.dp)) {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(modifier = GlanceModifier.size(8.dp).cornerRadius(4.dp).background(bagColor)) {}
|
||||
Spacer(GlanceModifier.width(6.dp))
|
||||
Line(bag.name, 12.sp, weight = FontWeight.Medium, modifier = GlanceModifier.defaultWeight())
|
||||
Line(weight(bag.grams) + (bag.limit?.let { " / ${weight(it)}" } ?: ""), 11.sp, GlanceTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
Spacer(GlanceModifier.height(4.dp))
|
||||
Bar(if (bag.limit != null && bag.limit > 0) bag.grams.toFloat() / bag.limit else bag.packed.toFloat() / bag.total.coerceAtLeast(1), bagColor, GlanceTheme.colors.surfaceVariant, 5.dp)
|
||||
}
|
||||
}
|
||||
|
||||
private fun weight(grams: Int): String = if (grams >= 1000) "%.1f kg".format(grams / 1000f) else "$grams g"
|
||||
}
|
||||
|
||||
// ---- Atlas ----
|
||||
|
||||
/** The countries the user has been to, glowing on the world map. Not tied to a trip. */
|
||||
class AtlasWidget : TrekWidget() {
|
||||
override val perTrip = false
|
||||
|
||||
@Composable
|
||||
override fun Content(snapshot: WidgetSnapshot?) {
|
||||
val context = LocalContext.current
|
||||
val size = LocalSize.current
|
||||
val labels = snapshot?.labels ?: return EmptyWidget(WidgetSamples.emptyText(context))
|
||||
val atlas = snapshot.atlas
|
||||
val codes = atlas?.codes?.toSet() ?: emptySet()
|
||||
WidgetFrame(onClick = openPath(context, "/atlas")) {
|
||||
Column(modifier = GlanceModifier.fillMaxSize()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Rounded.Public, GlanceTheme.colors.primary, 14.dp)
|
||||
Spacer(GlanceModifier.width(5.dp))
|
||||
Eyebrow(context.getString(com.liketrek.trek.R.string.widget_atlas_name), GlanceTheme.colors.primary)
|
||||
}
|
||||
Spacer(GlanceModifier.height(4.dp))
|
||||
val count: @Composable () -> Unit = {
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
Line((atlas?.count ?: 0).toString(), if (size.isWide) 34.sp else 30.sp, GlanceTheme.colors.onSurface, FontWeight.Bold)
|
||||
Spacer(GlanceModifier.width(6.dp))
|
||||
Column(modifier = GlanceModifier.padding(bottom = 4.dp)) {
|
||||
Line(labels.countries, 12.sp, GlanceTheme.colors.onSurface, FontWeight.Bold)
|
||||
// 195 countries make the world, as on the Atlas page.
|
||||
if (size.isWide) Line("${((atlas?.count ?: 0) * 100 / 195)}% ${labels.world}", 11.sp, GlanceTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (size.isWide && !size.isTall) {
|
||||
// Wide: the count beside the map, which gets the height.
|
||||
Row(modifier = GlanceModifier.fillMaxWidth().defaultWeight(), verticalAlignment = Alignment.CenterVertically) {
|
||||
count()
|
||||
Spacer(GlanceModifier.width(10.dp))
|
||||
WorldMap(codes, GlanceModifier.defaultWeight().fillMaxHeight())
|
||||
}
|
||||
} else {
|
||||
count()
|
||||
Spacer(GlanceModifier.height(6.dp))
|
||||
WorldMap(codes, GlanceModifier.fillMaxWidth().defaultWeight())
|
||||
}
|
||||
if (size.isTall && atlas != null) {
|
||||
Spacer(GlanceModifier.height(10.dp))
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
atlas.recent.take(4).forEach { country ->
|
||||
Column(modifier = GlanceModifier.defaultWeight(), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(flag(country.code), style = TextStyle(fontSize = 20.sp, textAlign = TextAlign.Center))
|
||||
Line(country.name, 10.sp, GlanceTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WorldMap(codes: Set<String>, modifier: GlanceModifier) {
|
||||
Box(modifier = modifier) {
|
||||
Image(
|
||||
provider = ImageProvider(WidgetArt.worldDots(codes, visited = false)),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(GlanceTheme.colors.outline),
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = GlanceModifier.fillMaxSize(),
|
||||
)
|
||||
Image(
|
||||
provider = ImageProvider(WidgetArt.worldDots(codes, visited = true)),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(GlanceTheme.colors.primary),
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = GlanceModifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The flag emoji of an ISO 3166-1 alpha-2 code. */
|
||||
private fun flag(code: String): String {
|
||||
if (code.length != 2) return ""
|
||||
val first = Character.toChars(0x1F1E6 + (code[0].uppercaseChar() - 'A'))
|
||||
val second = Character.toChars(0x1F1E6 + (code[1].uppercaseChar() - 'A'))
|
||||
return String(first) + String(second)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Travel group ----
|
||||
|
||||
/** Who comes along, the latest messages and the open poll. */
|
||||
class CollabWidget : TrekWidget() {
|
||||
@Composable
|
||||
override fun Content(snapshot: WidgetSnapshot?) {
|
||||
val context = LocalContext.current
|
||||
val size = LocalSize.current
|
||||
val labels = snapshot?.labels ?: return EmptyWidget(WidgetSamples.emptyText(context))
|
||||
val collab = snapshot.collab
|
||||
WidgetFrame(onClick = openPath(context, collab?.path ?: snapshot.trip?.path ?: "/")) {
|
||||
Column(modifier = GlanceModifier.fillMaxSize()) {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Rounded.Group, GlanceTheme.colors.primary, 14.dp)
|
||||
Spacer(GlanceModifier.width(5.dp))
|
||||
if (size.isWide) Eyebrow(labels.group, GlanceTheme.colors.primary, GlanceModifier.defaultWeight()) else Spacer(GlanceModifier.defaultWeight())
|
||||
collab?.members?.take(if (size.isWide) 4 else 3)?.forEach { member ->
|
||||
Spacer(GlanceModifier.width(2.dp))
|
||||
Initials(member.initials, 22.dp)
|
||||
}
|
||||
}
|
||||
Spacer(GlanceModifier.height(8.dp))
|
||||
val messages = collab?.messages.orEmpty()
|
||||
if (messages.isEmpty() && collab?.poll == null) {
|
||||
Spacer(GlanceModifier.defaultWeight())
|
||||
Line(labels.noMessages, 14.sp, GlanceTheme.colors.onSurfaceVariant, FontWeight.Medium, maxLines = 2)
|
||||
Spacer(GlanceModifier.defaultWeight())
|
||||
return@Column
|
||||
}
|
||||
messages.takeLast(if (size.isTall) (if (collab?.poll != null) 2 else 4) else 1).forEach { message ->
|
||||
Row(modifier = GlanceModifier.fillMaxWidth().padding(vertical = 3.dp), verticalAlignment = Alignment.Top) {
|
||||
if (!message.mine) {
|
||||
Initials(message.initials, 24.dp)
|
||||
Spacer(GlanceModifier.width(6.dp))
|
||||
} else {
|
||||
Spacer(GlanceModifier.defaultWeight())
|
||||
}
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
.background(if (message.mine) GlanceTheme.colors.primary else GlanceTheme.colors.surfaceVariant)
|
||||
.cornerRadius(14.dp).padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
) {
|
||||
if (!message.mine && size.isWide) Line(message.author, 10.sp, GlanceTheme.colors.onSurfaceVariant, FontWeight.Bold)
|
||||
Line(
|
||||
message.text,
|
||||
12.sp,
|
||||
if (message.mine) GlanceTheme.colors.onPrimary else GlanceTheme.colors.onSurface,
|
||||
maxLines = if (size.isWide) 2 else 3,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val poll = collab?.poll
|
||||
if (size.isTall && poll != null) {
|
||||
Spacer(GlanceModifier.defaultWeight())
|
||||
Column(modifier = GlanceModifier.fillMaxWidth().background(GlanceTheme.colors.secondaryContainer).cornerRadius(14.dp).padding(10.dp)) {
|
||||
Eyebrow("${labels.poll}: ${poll.voters} ${labels.votes}", GlanceTheme.colors.onSecondaryContainer)
|
||||
Line(poll.question, 13.sp, GlanceTheme.colors.onSecondaryContainer, FontWeight.Bold)
|
||||
Spacer(GlanceModifier.height(4.dp))
|
||||
val most = poll.options.maxOfOrNull { it.votes }?.coerceAtLeast(1) ?: 1
|
||||
poll.options.take(3).forEach { option ->
|
||||
Row(modifier = GlanceModifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Line(option.label, 12.sp, GlanceTheme.colors.onSecondaryContainer, modifier = GlanceModifier.width(70.dp))
|
||||
Bar(option.votes.toFloat() / most, GlanceTheme.colors.primary, GlanceTheme.colors.surface, 5.dp, GlanceModifier.defaultWeight())
|
||||
Spacer(GlanceModifier.width(6.dp))
|
||||
Line(option.votes.toString(), 11.sp, GlanceTheme.colors.onSecondaryContainer, FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Initials(text: String, size: androidx.compose.ui.unit.Dp) {
|
||||
Box(
|
||||
modifier = GlanceModifier.size(size).cornerRadius(size / 2).background(GlanceTheme.colors.tertiaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(text, style = TextStyle(color = GlanceTheme.colors.onTertiaryContainer, fontSize = 9.sp, fontWeight = FontWeight.Bold), maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A widget with nothing to show yet: TREK's mark and what to do. */
|
||||
@Composable
|
||||
fun EmptyWidget(text: String) {
|
||||
val context = LocalContext.current
|
||||
WidgetFrame(onClick = openPath(context, "/")) {
|
||||
Column(modifier = GlanceModifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = GlanceModifier.size(34.dp).cornerRadius(11.dp).background(GlanceTheme.colors.primary),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text("T", style = TextStyle(color = GlanceTheme.colors.onPrimary, fontSize = 18.sp, fontWeight = FontWeight.Bold))
|
||||
}
|
||||
Spacer(GlanceModifier.height(10.dp))
|
||||
Line(text, 13.sp, GlanceTheme.colors.onSurface, FontWeight.Medium, maxLines = 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
package com.liketrek.trek.widget
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.LinearGradient
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.RadialGradient
|
||||
import android.graphics.Shader
|
||||
import android.util.Base64
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* The pictures a widget shows that RemoteViews cannot draw: the trip's cover,
|
||||
* its own sky when it has none, and the world map's dots. Drawn once per
|
||||
* update into bitmaps, kept small (a widget's RemoteViews have a size budget).
|
||||
*/
|
||||
object WidgetArt {
|
||||
/** A stable number for a text (FNV-1a), the same as TrekStyle.swift's seed. */
|
||||
fun seed(text: String): Long {
|
||||
var hash = 1469598103934665603L
|
||||
for (byte in text.toByteArray(Charsets.UTF_8)) {
|
||||
hash = (hash xor (byte.toLong() and 0xff)) * 1099511628211L
|
||||
}
|
||||
return hash
|
||||
}
|
||||
|
||||
/** The trip's two colours, from its title: the same trip always gets the same sky. */
|
||||
fun tripHues(title: String): Pair<Int, Int> {
|
||||
val base = (java.lang.Long.remainderUnsigned(seed(title), 360)).toFloat()
|
||||
val first = Color.HSVToColor(floatArrayOf(base, 0.62f, 0.78f))
|
||||
val second = Color.HSVToColor(floatArrayOf((base + 40f) % 360f, 0.7f, 0.42f))
|
||||
return first to second
|
||||
}
|
||||
|
||||
/** The cover the app sent, base64 JPEG, decoded down to at most [maxSide] pixels. */
|
||||
fun cover(base64: String?, maxSide: Int = 720): Bitmap? {
|
||||
if (base64.isNullOrBlank()) return null
|
||||
return try {
|
||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||
var sample = 1
|
||||
while (bounds.outWidth / (sample * 2) >= maxSide || bounds.outHeight / (sample * 2) >= maxSide) sample *= 2
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, BitmapFactory.Options().apply { inSampleSize = sample })
|
||||
} catch (e: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** A trip without a cover: its two colours as a sky, with contour lines over it. */
|
||||
fun sky(title: String, width: Int = 480, height: Int = 480): Bitmap {
|
||||
val (first, second) = tripHues(title)
|
||||
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bitmap)
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
paint.shader = LinearGradient(0f, 0f, width.toFloat(), height.toFloat(), first, second, Shader.TileMode.CLAMP)
|
||||
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint)
|
||||
paint.shader = RadialGradient(width * 0.85f, height * 0.05f, width * 0.6f, Color.argb(70, 255, 255, 255), Color.TRANSPARENT, Shader.TileMode.CLAMP)
|
||||
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint)
|
||||
paint.shader = null
|
||||
drawContours(canvas, width, height, java.lang.Long.remainderUnsigned(seed(title), 997).toInt(), Color.argb(36, 255, 255, 255))
|
||||
return bitmap
|
||||
}
|
||||
|
||||
/** Concentric wobbly rings, the topographic lines that stand for travel in TREK. */
|
||||
private fun drawContours(canvas: Canvas, width: Int, height: Int, seed: Int, color: Int) {
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = 2f
|
||||
this.color = color
|
||||
}
|
||||
val cx = width * (0.25f + (seed % 50) / 100f)
|
||||
val cy = height * (0.35f + (seed % 30) / 100f)
|
||||
val maxR = min(width, height) * 1.1f
|
||||
val rings = 11
|
||||
for (ring in 1..rings) {
|
||||
val r = maxR * ring / rings
|
||||
val path = Path()
|
||||
val steps = 72
|
||||
for (i in 0..steps) {
|
||||
val a = 2 * PI * i / steps
|
||||
val wobble = 1 + 0.08 * sin(a * 3 + seed + ring) + 0.05 * cos(a * 5 + seed * 0.7)
|
||||
val x = cx + (r * wobble * cos(a)).toFloat()
|
||||
val y = cy + (r * wobble * sin(a) * 0.8).toFloat()
|
||||
if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
|
||||
}
|
||||
canvas.drawPath(path, paint)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The world map as dots, white so the widget tints it: [visited] true draws
|
||||
* only the countries in [codes], false only the others.
|
||||
*/
|
||||
fun worldDots(codes: Set<String>, visited: Boolean, dot: Int = 8): Bitmap {
|
||||
val rows = WorldGrid.ROWS.size
|
||||
val bitmap = Bitmap.createBitmap(WorldGrid.COLUMNS * dot, rows * dot, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bitmap)
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { this.color = Color.WHITE }
|
||||
val radius = dot * 0.36f
|
||||
for (row in 0 until rows) {
|
||||
for (column in 0 until WorldGrid.COLUMNS) {
|
||||
val code = WorldGrid.code(row, column) ?: continue
|
||||
if ((code in codes) != visited) continue
|
||||
canvas.drawCircle(column * dot + dot / 2f, row * dot + dot / 2f, radius, paint)
|
||||
}
|
||||
}
|
||||
return bitmap
|
||||
}
|
||||
|
||||
/** A ring of [progress] (0..1), white on a faint track, for the countdown and the packing. */
|
||||
fun ring(progress: Float, sizePx: Int = 160, stroke: Float = 14f, track: Int = Color.argb(60, 255, 255, 255)): Bitmap {
|
||||
val bitmap = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bitmap)
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = stroke
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
}
|
||||
val inset = stroke / 2 + 1
|
||||
val oval = android.graphics.RectF(inset, inset, sizePx - inset, sizePx - inset)
|
||||
paint.color = track
|
||||
canvas.drawArc(oval, 0f, 360f, false, paint)
|
||||
paint.color = Color.WHITE
|
||||
val sweep = 360f * progress.coerceIn(0f, 1f)
|
||||
if (sweep > 0) canvas.drawArc(oval, -90f, sweep, false, paint)
|
||||
return bitmap
|
||||
}
|
||||
|
||||
/** A trip's days as segments, filled up to [index]: white, the widget tints it. Track at a third. */
|
||||
fun segments(index: Int, total: Int, width: Int = 600, height: Int = 10): Bitmap {
|
||||
val count = total.coerceIn(1, 31)
|
||||
val filled = if (total > 31) (index * 31f / total).toInt().coerceAtLeast(1) else index
|
||||
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bitmap)
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
val gap = if (count > 14) 4f else 8f
|
||||
val w = (width - gap * (count - 1)) / count
|
||||
for (i in 0 until count) {
|
||||
paint.color = if (i < filled) Color.WHITE else Color.argb(85, 255, 255, 255)
|
||||
val left = i * (w + gap)
|
||||
canvas.drawRoundRect(android.graphics.RectF(left, 0f, left + w, height.toFloat()), height / 2f, height / 2f, paint)
|
||||
}
|
||||
return bitmap
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
package com.liketrek.trek.widget
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.AutoAwesome
|
||||
import androidx.compose.material.icons.rounded.Luggage
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LargeTopAppBar
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.liketrek.trek.R
|
||||
import com.liketrek.trek.ui.TrekMaterialTheme
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* "Choose trip" for a placed widget, the counterpart of the iOS widget's Edit
|
||||
* Widget (TripIntent.swift): Automatic follows the dashboard's trip, or one of
|
||||
* the trips the app listed (key "trips"). Shown when the widget is placed
|
||||
* (optional, it starts on Automatic) and again from the widget's long press.
|
||||
*/
|
||||
class WidgetConfigActivity : ComponentActivity() {
|
||||
private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
appWidgetId = intent?.extras?.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
|
||||
?: AppWidgetManager.INVALID_APPWIDGET_ID
|
||||
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
// Backing out keeps the widget on what it had (Automatic for a new one).
|
||||
setResult(RESULT_OK, Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId))
|
||||
val trips = WidgetStore.trips(this)
|
||||
val current = WidgetStore.tripFor(this, appWidgetId)
|
||||
setContent {
|
||||
TrekMaterialTheme {
|
||||
TripPicker(trips, current, onPick = ::pick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun pick(tripId: String) {
|
||||
WidgetStore.setTripFor(this, appWidgetId, tripId)
|
||||
lifecycleScope.launch {
|
||||
val manager = GlanceAppWidgetManager(this@WidgetConfigActivity)
|
||||
val glanceId = runCatching { manager.getGlanceIdBy(appWidgetId) }.getOrNull()
|
||||
// Only the widget this id belongs to: Glance keys a session by the id alone,
|
||||
// so another class updating it would draw its own layout there.
|
||||
val widget = TrekWidgetUpdater.widgetFor(this@WidgetConfigActivity, appWidgetId)
|
||||
if (glanceId != null && widget != null) runCatching { widget.update(this@WidgetConfigActivity, glanceId) }
|
||||
setResult(RESULT_OK, Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId))
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TripPicker(trips: List<TripRef>, current: String, onPick: (String) -> Unit) {
|
||||
var selected by remember { mutableStateOf(current) }
|
||||
val scroll = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection),
|
||||
topBar = {
|
||||
LargeTopAppBar(
|
||||
title = { Text(stringResource(R.string.widget_intent_title)) },
|
||||
scrollBehavior = scroll,
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = padding.calculateTopPadding(), bottom = padding.calculateBottomPadding() + 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.widget_intent_description),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 4.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
item {
|
||||
PickRow(
|
||||
title = stringResource(R.string.widget_intent_auto),
|
||||
subtitle = stringResource(R.string.widget_intent_auto_hint),
|
||||
leading = { Icon(Icons.Rounded.AutoAwesome, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
checked = selected == WidgetStore.AUTO,
|
||||
) {
|
||||
selected = WidgetStore.AUTO
|
||||
onPick(WidgetStore.AUTO)
|
||||
}
|
||||
}
|
||||
items(trips, key = { it.id }) { trip ->
|
||||
PickRow(
|
||||
title = trip.title,
|
||||
subtitle = RelativeDayRange(trip),
|
||||
leading = { Icon(Icons.Rounded.Luggage, null, tint = MaterialTheme.colorScheme.secondary) },
|
||||
checked = selected == trip.id.toString(),
|
||||
) {
|
||||
selected = trip.id.toString()
|
||||
onPick(trip.id.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RelativeDayRange(trip: TripRef): String? {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
return RelativeDay.range(context, trip.start, trip.end)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PickRow(title: String, subtitle: String?, leading: @Composable () -> Unit, checked: Boolean, onClick: () -> Unit) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = if (checked) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||
) {
|
||||
Row(modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Surface(shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.surface, modifier = Modifier.size(40.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { leading() }
|
||||
}
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (subtitle != null) {
|
||||
Text(subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
|
||||
}
|
||||
}
|
||||
RadioButton(selected = checked, onClick = onClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
package com.liketrek.trek.widget
|
||||
|
||||
import android.content.Context
|
||||
import com.liketrek.trek.R
|
||||
import java.time.LocalDate
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* The sample trip the widget picker shows before the app ever wrote a snapshot,
|
||||
* and what the screenshot tests draw: the same trip to Kyoto as the iOS gallery
|
||||
* (WidgetSnapshot.sample in TrekFamily.swift).
|
||||
*/
|
||||
object WidgetSamples {
|
||||
fun emptyText(context: Context): String = context.getString(R.string.widget_empty)
|
||||
|
||||
fun snapshot(running: Boolean, now: LocalDate = LocalDate.now()): WidgetSnapshot = WidgetSnapshot.fromJson(JSONObject(json(running, now)))
|
||||
|
||||
fun json(running: Boolean, now: LocalDate = LocalDate.now()): String {
|
||||
fun iso(offset: Long) = now.plusDays(offset).toString()
|
||||
val start = if (running) -2L else 12L
|
||||
val today = if (running) {
|
||||
"""
|
||||
"today": {"dayLabel": "Day 3 of 9", "index": 3, "total": 9, "stops": [
|
||||
{"time": "08:30", "name": "Fushimi Inari Taisha", "minutes": 510, "color": "#D6273B", "symbol": "building.columns.fill"},
|
||||
{"time": "11:00", "name": "Nishiki Market", "minutes": 660, "color": "#E8A13A", "symbol": "storefront.fill"},
|
||||
{"time": "13:30", "name": "Kiyomizu-dera", "minutes": 810, "color": "#2FA37A", "symbol": "leaf.fill"},
|
||||
{"time": "16:00", "name": "Gion walk", "minutes": 960, "color": "#4A7DDB", "symbol": "figure.walk"},
|
||||
{"time": "19:30", "name": "Dinner at Pontocho", "minutes": 1170, "color": "#8E6CD9", "symbol": "fork.knife"}
|
||||
]},
|
||||
"""
|
||||
} else {
|
||||
""
|
||||
}
|
||||
return """
|
||||
{
|
||||
"trip": {"title": "Autumn in Kyoto", "start": "${iso(start)}", "end": "${iso(start + 8)}", "path": "/trips/1",
|
||||
"place": "Kyoto, Japan", "dayCount": 9, "placeCount": 34, "memberCount": 3},
|
||||
$today
|
||||
"weather": {"temp": 18, "high": 21, "low": 11, "main": "${if (running) "Clear" else "Clouds"}", "label": "Mostly sunny", "place": "Kyoto", "rain": 10},
|
||||
"bookings": [
|
||||
{"title": "LH 716", "kind": "flight", "date": "${iso(if (running) 6 else 12)}", "time": "13:05", "detail": "FRA → HND", "confirmed": true, "path": "/trips/1"},
|
||||
{"title": "Shinkansen Nozomi 21", "kind": "train", "date": "${iso(if (running) 0 else 13)}", "time": "09:12", "detail": "Tokyo → Kyoto", "confirmed": true, "path": "/trips/1"},
|
||||
{"title": "Hotel Kanra Kyoto", "kind": "hotel", "date": "${iso(if (running) 0 else 13)}", "time": "15:00", "detail": "6 nights", "confirmed": true, "path": "/trips/1"},
|
||||
{"title": "Kaiseki at Kikunoi", "kind": "restaurant", "date": "${iso(if (running) 1 else 15)}", "time": "19:00", "detail": "Higashiyama", "confirmed": false, "path": "/trips/1"},
|
||||
{"title": "Tea ceremony", "kind": "event", "date": "${iso(if (running) 2 else 16)}", "time": "10:30", "detail": "Camellia Flower", "confirmed": true, "path": "/trips/1"}
|
||||
],
|
||||
"todos": {"open": 4, "total": 11, "path": "/trips/1/todos", "items": [
|
||||
{"name": "Order JR Pass", "due": "${iso(if (running) 0 else 3)}", "priority": 3},
|
||||
{"name": "Book Ghibli Museum tickets", "due": "${iso(if (running) 1 else 5)}", "priority": 2},
|
||||
{"name": "Get yen in cash", "priority": 1},
|
||||
{"name": "Download offline maps", "priority": 0}
|
||||
]},
|
||||
"packing": {"packed": 23, "total": 31, "path": "/trips/1/packing",
|
||||
"bags": [
|
||||
{"name": "Suitcase", "color": "#4A7DDB", "grams": 14200, "limit": 23000, "packed": 15, "total": 19},
|
||||
{"name": "Daypack", "color": "#2FA37A", "grams": 4100, "limit": 8000, "packed": 8, "total": 12}
|
||||
],
|
||||
"missing": [
|
||||
{"name": "Rain jacket", "category": "Clothes", "color": "#4A7DDB"},
|
||||
{"name": "Power bank", "category": "Tech", "color": "#2FA37A"},
|
||||
{"name": "Passport", "category": "Documents", "color": "#2FA37A"},
|
||||
{"name": "Sandals", "category": "Shoes", "color": "#4A7DDB"}
|
||||
]},
|
||||
"atlas": {"count": 23, "trips": 41, "days": 318,
|
||||
"codes": ["DE","AT","CH","FR","IT","ES","PT","NL","BE","DK","NO","SE","GB","IE","GR","HR","JP","TH","VN","US","CA","MA","AU"],
|
||||
"recent": [{"code": "JP", "name": "Japan"}, {"code": "NO", "name": "Norway"}, {"code": "PT", "name": "Portugal"}, {"code": "MA", "name": "Morocco"}],
|
||||
"continents": [{"name": "Europe", "count": 16}, {"name": "Asia", "count": 3}, {"name": "North America", "count": 2}, {"name": "Africa", "count": 1}, {"name": "Oceania", "count": 1}]},
|
||||
"collab": {"path": "/trips/1?tab=collab",
|
||||
"members": [{"name": "Maurice", "initials": "MA"}, {"name": "Julien", "initials": "JU"}, {"name": "Elmo", "initials": "EL"}, {"name": "Sara", "initials": "SA"}],
|
||||
"messages": [
|
||||
{"author": "Julien", "initials": "JU", "text": "Got the JR Pass vouchers, all four of us", "mine": false},
|
||||
{"author": "Sara", "initials": "SA", "text": "Can we push dinner to 20:00? Train is late", "mine": false},
|
||||
{"author": "Maurice", "initials": "MA", "text": "Sure, I moved the table at Pontocho", "mine": true}
|
||||
],
|
||||
"poll": {"question": "Day trip on Saturday?", "voters": 4, "options": [{"label": "Nara", "votes": 3}, {"label": "Uji", "votes": 1}, {"label": "Kurama", "votes": 0}]}},
|
||||
"labels": {
|
||||
"nextTrip": "Next trip", "today": "Today", "noTrip": "No trip coming up. Plan the next one in TREK.",
|
||||
"inDays": "in {n} days", "tomorrow": "Tomorrow", "nothingPlanned": "Nothing planned today",
|
||||
"days": "days", "bookings": "Coming up", "noBookings": "No bookings ahead",
|
||||
"todos": "To-dos", "noTodos": "All done", "packing": "Packing", "packed": "packed",
|
||||
"confirmed": "Confirmed", "pending": "Pending", "todayShort": "Today", "tomorrowShort": "Tomorrow",
|
||||
"now": "Now", "places": "places", "travellers": "travellers", "allDone": "Day done",
|
||||
"missing": "still to pack", "countries": "countries", "world": "of the world", "group": "Travel group",
|
||||
"poll": "Poll", "votes": "votes", "noMessages": "No messages yet", "departure": "Departure"
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
package com.liketrek.trek.widget
|
||||
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeParseException
|
||||
import java.time.temporal.ChronoUnit
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* What the app last told the widgets: client/src/native/widgets.ts builds it and
|
||||
* hands it over through TrekShell.setWidgetSnapshot, the same JSON the iOS
|
||||
* widgets read (client/ios/App/TREKWidgets/WidgetSnapshot.swift). Texts arrive
|
||||
* already translated, so a widget always speaks the language the app does.
|
||||
*
|
||||
* Everything past trip, today and labels is optional: a binary newer than the
|
||||
* server's UI shows what it gets, and unknown fields are ignored.
|
||||
*/
|
||||
data class WidgetSnapshot(
|
||||
val trip: Trip?,
|
||||
val today: Today?,
|
||||
val weather: Weather?,
|
||||
val bookings: List<Booking>,
|
||||
val todos: Todos?,
|
||||
val packing: Packing?,
|
||||
val atlas: Atlas?,
|
||||
val collab: Collab?,
|
||||
val labels: Labels,
|
||||
) {
|
||||
data class Trip(
|
||||
val title: String,
|
||||
val start: String,
|
||||
val end: String,
|
||||
val path: String,
|
||||
val place: String?,
|
||||
/** A small JPEG of the cover, base64. */
|
||||
val cover: String?,
|
||||
val dayCount: Int?,
|
||||
val placeCount: Int?,
|
||||
val memberCount: Int?,
|
||||
)
|
||||
|
||||
data class Stop(
|
||||
val time: String?,
|
||||
val name: String,
|
||||
/** Minutes after midnight. */
|
||||
val minutes: Int?,
|
||||
/** #rrggbb */
|
||||
val color: String?,
|
||||
/** The SF Symbol name the iOS widget uses; [TrekIcons] maps it. */
|
||||
val symbol: String?,
|
||||
)
|
||||
|
||||
data class Today(val dayLabel: String, val stops: List<Stop>, val index: Int?, val total: Int?)
|
||||
|
||||
data class Weather(
|
||||
val temp: Int,
|
||||
val high: Int?,
|
||||
val low: Int?,
|
||||
val main: String,
|
||||
val label: String,
|
||||
val place: String?,
|
||||
val rain: Int?,
|
||||
)
|
||||
|
||||
data class Booking(
|
||||
val title: String,
|
||||
val kind: String,
|
||||
val date: String?,
|
||||
val time: String?,
|
||||
val detail: String?,
|
||||
val confirmed: Boolean,
|
||||
val path: String,
|
||||
)
|
||||
|
||||
data class Todo(val name: String, val due: String?, val priority: Int)
|
||||
|
||||
data class Todos(val open: Int, val total: Int, val items: List<Todo>, val path: String)
|
||||
|
||||
data class Bag(val name: String, val color: String, val grams: Int, val limit: Int?, val packed: Int, val total: Int)
|
||||
|
||||
data class PackItem(val name: String, val category: String?, val color: String?)
|
||||
|
||||
data class Packing(val packed: Int, val total: Int, val path: String, val bags: List<Bag>, val missing: List<PackItem>)
|
||||
|
||||
data class Country(val code: String, val name: String)
|
||||
|
||||
data class Continent(val name: String, val count: Int)
|
||||
|
||||
data class Atlas(
|
||||
val count: Int,
|
||||
val codes: List<String>,
|
||||
val recent: List<Country>,
|
||||
val continents: List<Continent>,
|
||||
val trips: Int?,
|
||||
val days: Int?,
|
||||
)
|
||||
|
||||
data class Member(val name: String, val initials: String)
|
||||
|
||||
data class Message(val author: String, val initials: String, val text: String, val at: String?, val mine: Boolean)
|
||||
|
||||
data class PollOption(val label: String, val votes: Int)
|
||||
|
||||
data class Poll(val question: String, val options: List<PollOption>, val voters: Int)
|
||||
|
||||
data class Collab(val members: List<Member>, val messages: List<Message>, val poll: Poll?, val path: String)
|
||||
|
||||
/** The labels, translated by the app. The optional ones fall back to English. */
|
||||
data class Labels(private val raw: JSONObject) {
|
||||
val nextTrip get() = text("nextTrip", "Next trip")
|
||||
val today get() = text("today", "Today")
|
||||
val noTrip get() = text("noTrip", "No trip coming up. Plan the next one in TREK.")
|
||||
val inDays get() = text("inDays", "in {n} days")
|
||||
val tomorrow get() = text("tomorrow", "Tomorrow")
|
||||
val nothingPlanned get() = text("nothingPlanned", "Nothing planned today")
|
||||
val days get() = text("days", "days")
|
||||
val bookings get() = text("bookings", "Coming up")
|
||||
val noBookings get() = text("noBookings", "No bookings ahead")
|
||||
val todos get() = text("todos", "To-dos")
|
||||
val noTodos get() = text("noTodos", "All done")
|
||||
val packing get() = text("packing", "Packing")
|
||||
val packed get() = text("packed", "packed")
|
||||
val confirmed get() = text("confirmed", "Confirmed")
|
||||
val pending get() = text("pending", "Pending")
|
||||
val todayShort get() = text("todayShort", today)
|
||||
val tomorrowShort get() = text("tomorrowShort", tomorrow)
|
||||
val now get() = text("now", "Now")
|
||||
val places get() = text("places", "places")
|
||||
val travellers get() = text("travellers", "travellers")
|
||||
val allDone get() = text("allDone", "Day done")
|
||||
val openTrek get() = text("openTrek", "Open TREK")
|
||||
val missing get() = text("missing", "still to pack")
|
||||
val countries get() = text("countries", "countries")
|
||||
val world get() = text("world", "of the world")
|
||||
val group get() = text("group", "Travel group")
|
||||
val poll get() = text("poll", "Poll")
|
||||
val votes get() = text("votes", "votes")
|
||||
val noMessages get() = text("noMessages", "No messages yet")
|
||||
val departure get() = text("departure", "Departure")
|
||||
|
||||
private fun text(key: String, fallback: String): String = raw.optString(key).ifBlank { fallback }
|
||||
}
|
||||
|
||||
/** Whole days from [now] to the trip's start. */
|
||||
fun daysUntilStart(now: LocalDate = LocalDate.now()): Long? = trip?.let { day(it.start) }?.let { ChronoUnit.DAYS.between(now, it) }
|
||||
|
||||
/** The trip's length in days, from its dates when the app sent no count. */
|
||||
val tripLength: Int?
|
||||
get() {
|
||||
trip?.dayCount?.takeIf { it > 0 }?.let { return it }
|
||||
val start = trip?.let { day(it.start) } ?: return null
|
||||
val end = day(trip.end) ?: return null
|
||||
return ChronoUnit.DAYS.between(start, end).toInt() + 1
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun parse(json: String): WidgetSnapshot? = try {
|
||||
fromJson(JSONObject(json))
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
fun fromJson(o: JSONObject): WidgetSnapshot = WidgetSnapshot(
|
||||
trip = o.optJSONObject("trip")?.let {
|
||||
Trip(
|
||||
title = it.optString("title"),
|
||||
start = it.optString("start"),
|
||||
end = it.optString("end"),
|
||||
path = it.optString("path", "/"),
|
||||
place = it.str("place"),
|
||||
cover = it.str("cover"),
|
||||
dayCount = it.int("dayCount"),
|
||||
placeCount = it.int("placeCount"),
|
||||
memberCount = it.int("memberCount"),
|
||||
)
|
||||
},
|
||||
today = o.optJSONObject("today")?.let { t ->
|
||||
Today(
|
||||
dayLabel = t.optString("dayLabel"),
|
||||
stops = t.optJSONArray("stops").objects().map {
|
||||
Stop(it.str("time"), it.optString("name"), it.int("minutes"), it.str("color"), it.str("symbol"))
|
||||
},
|
||||
index = t.int("index"),
|
||||
total = t.int("total"),
|
||||
)
|
||||
},
|
||||
weather = o.optJSONObject("weather")?.let {
|
||||
Weather(it.optInt("temp"), it.int("high"), it.int("low"), it.optString("main"), it.optString("label"), it.str("place"), it.int("rain"))
|
||||
},
|
||||
bookings = o.optJSONArray("bookings").objects().map {
|
||||
Booking(
|
||||
title = it.optString("title"),
|
||||
kind = it.optString("kind", "other"),
|
||||
date = it.str("date"),
|
||||
time = it.str("time"),
|
||||
detail = it.str("detail"),
|
||||
confirmed = it.optBoolean("confirmed"),
|
||||
path = it.optString("path", "/"),
|
||||
)
|
||||
},
|
||||
todos = o.optJSONObject("todos")?.let { t ->
|
||||
Todos(
|
||||
open = t.optInt("open"),
|
||||
total = t.optInt("total"),
|
||||
items = t.optJSONArray("items").objects().map { Todo(it.optString("name"), it.str("due"), it.optInt("priority")) },
|
||||
path = t.optString("path", "/"),
|
||||
)
|
||||
},
|
||||
packing = o.optJSONObject("packing")?.let { p ->
|
||||
Packing(
|
||||
packed = p.optInt("packed"),
|
||||
total = p.optInt("total"),
|
||||
path = p.optString("path", "/"),
|
||||
bags = p.optJSONArray("bags").objects().map {
|
||||
Bag(it.optString("name"), it.optString("color", "#4A7DDB"), it.optInt("grams"), it.int("limit"), it.optInt("packed"), it.optInt("total"))
|
||||
},
|
||||
missing = p.optJSONArray("missing").objects().map { PackItem(it.optString("name"), it.str("category"), it.str("color")) },
|
||||
)
|
||||
},
|
||||
atlas = o.optJSONObject("atlas")?.let { a ->
|
||||
Atlas(
|
||||
count = a.optInt("count"),
|
||||
codes = a.optJSONArray("codes").strings(),
|
||||
recent = a.optJSONArray("recent").objects().map { Country(it.optString("code"), it.optString("name")) },
|
||||
continents = a.optJSONArray("continents").objects().map { Continent(it.optString("name"), it.optInt("count")) },
|
||||
trips = a.int("trips"),
|
||||
days = a.int("days"),
|
||||
)
|
||||
},
|
||||
collab = o.optJSONObject("collab")?.let { c ->
|
||||
Collab(
|
||||
members = c.optJSONArray("members").objects().map { Member(it.optString("name"), it.optString("initials")) },
|
||||
messages = c.optJSONArray("messages").objects().map {
|
||||
Message(it.optString("author"), it.optString("initials"), it.optString("text"), it.str("at"), it.optBoolean("mine"))
|
||||
},
|
||||
poll = c.optJSONObject("poll")?.let { p ->
|
||||
Poll(p.optString("question"), p.optJSONArray("options").objects().map { PollOption(it.optString("label"), it.optInt("votes")) }, p.optInt("voters"))
|
||||
},
|
||||
path = c.optString("path", "/"),
|
||||
)
|
||||
},
|
||||
labels = Labels(o.optJSONObject("labels") ?: JSONObject()),
|
||||
)
|
||||
|
||||
fun day(iso: String?): LocalDate? {
|
||||
if (iso.isNullOrBlank() || iso.length < 10) return null
|
||||
return try {
|
||||
LocalDate.parse(iso.substring(0, 10))
|
||||
} catch (e: DateTimeParseException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** The app link a tap opens: com.liketrek.trek://navigate<path>, which the UI routes (nativeApp.ts). */
|
||||
fun link(path: String): String = "com.liketrek.trek://navigate" + if (path.startsWith("/")) path else "/$path"
|
||||
|
||||
private fun JSONObject.str(key: String): String? = if (isNull(key)) null else optString(key).ifEmpty { null }
|
||||
|
||||
private fun JSONObject.int(key: String): Int? = if (has(key) && !isNull(key)) optInt(key) else null
|
||||
|
||||
private fun JSONArray?.objects(): List<JSONObject> =
|
||||
if (this == null) emptyList() else (0 until length()).mapNotNull { optJSONObject(it) }
|
||||
|
||||
private fun JSONArray?.strings(): List<String> =
|
||||
if (this == null) emptyList() else (0 until length()).mapNotNull { optString(it).ifEmpty { null } }
|
||||
}
|
||||
}
|
||||
|
||||
/** A trip a widget can be set to, as the app lists them (key "trips"). */
|
||||
data class TripRef(val id: Long, val title: String, val start: String, val end: String) {
|
||||
companion object {
|
||||
fun parseList(json: String?): List<TripRef> {
|
||||
if (json.isNullOrBlank()) return emptyList()
|
||||
return try {
|
||||
val array = JSONArray(json)
|
||||
(0 until array.length()).mapNotNull { i ->
|
||||
val o = array.optJSONObject(i) ?: return@mapNotNull null
|
||||
if (!o.has("id")) return@mapNotNull null
|
||||
TripRef(o.optLong("id"), o.optString("title"), o.optString("start"), o.optString("end"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package com.liketrek.trek.widget
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Where the widgets' snapshots live: one JSON file per key in the app's own
|
||||
* storage. The widgets run in the app's process, so no shared container is
|
||||
* needed as on iOS; the keys are the same ones the UI writes there
|
||||
* ("snapshot", "trips", "trip.<id>").
|
||||
*/
|
||||
object WidgetStore {
|
||||
private val KEY = Regex("^(snapshot|trips|trip\\.[0-9]{1,12})$")
|
||||
private const val PREFS = "trek.widgets"
|
||||
const val AUTO = "auto"
|
||||
|
||||
private val changes = MutableStateFlow(0L)
|
||||
|
||||
/**
|
||||
* Counts every write and every trip picked for a widget. A widget reads its
|
||||
* data inside its composition, keyed on this, so a write that lands while its
|
||||
* Glance session still runs is drawn too, not only the first one.
|
||||
*/
|
||||
val version: StateFlow<Long> = changes
|
||||
|
||||
fun isValidKey(key: String): Boolean = KEY.matches(key)
|
||||
|
||||
private fun dir(context: Context): File = File(context.filesDir, "widgets").apply { mkdirs() }
|
||||
|
||||
fun write(context: Context, key: String, json: String) {
|
||||
require(isValidKey(key)) { "key must be snapshot, trips or trip.<id>" }
|
||||
val target = File(dir(context), "$key.json")
|
||||
val temp = File(dir(context), "$key.json.tmp")
|
||||
temp.writeText(json)
|
||||
if (!temp.renameTo(target)) {
|
||||
target.delete()
|
||||
temp.renameTo(target)
|
||||
}
|
||||
changes.value++
|
||||
}
|
||||
|
||||
fun read(context: Context, key: String): String? {
|
||||
if (!isValidKey(key)) return null
|
||||
val file = File(dir(context), "$key.json")
|
||||
return if (file.exists()) runCatching { file.readText() }.getOrNull() else null
|
||||
}
|
||||
|
||||
/** The dashboard's snapshot. */
|
||||
fun load(context: Context): WidgetSnapshot? = read(context, "snapshot")?.let(WidgetSnapshot::parse)
|
||||
|
||||
/**
|
||||
* The snapshot of the trip a widget is set to; the dashboard's for none or
|
||||
* "auto", and the dashboard's too while the app has not written that trip.
|
||||
* A trip's snapshot carries no Atlas, so it borrows the dashboard's.
|
||||
*/
|
||||
fun load(context: Context, tripId: String?): WidgetSnapshot? {
|
||||
if (tripId == null || tripId == AUTO) return load(context)
|
||||
val own = read(context, "trip.$tripId")?.let(WidgetSnapshot::parse) ?: return load(context)
|
||||
return if (own.atlas == null) own.copy(atlas = load(context)?.atlas) else own
|
||||
}
|
||||
|
||||
fun trips(context: Context): List<TripRef> = TripRef.parseList(read(context, "trips"))
|
||||
|
||||
/** The trip picked for one placed widget, "auto" when none was. */
|
||||
fun tripFor(context: Context, appWidgetId: Int): String =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString("trip.$appWidgetId", AUTO) ?: AUTO
|
||||
|
||||
fun setTripFor(context: Context, appWidgetId: Int, tripId: String) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString("trip.$appWidgetId", tripId).commit()
|
||||
changes.value++
|
||||
}
|
||||
|
||||
fun forget(context: Context, appWidgetIds: IntArray) {
|
||||
val editor = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||
for (id in appWidgetIds) editor.remove("trip.$id")
|
||||
editor.apply()
|
||||
}
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
package com.liketrek.trek.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.text.format.DateFormat
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.glance.ColorFilter
|
||||
import androidx.glance.GlanceComposable
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.Image
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.action.Action
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.appWidgetBackground
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.background
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.height
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.layout.size
|
||||
import androidx.glance.layout.width
|
||||
import androidx.glance.material3.ColorProviders
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import androidx.glance.unit.ColorProvider
|
||||
import com.liketrek.trek.MainActivity
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* What every TREK widget shares: Material You's colours on Android 12 and later
|
||||
* (the wallpaper's), TREK's own before that, the system's corner radius, the
|
||||
* tap that opens a place in the app, and the small pieces (eyebrow, chip, icon
|
||||
* badge) the widgets are built from.
|
||||
*/
|
||||
object TrekWidgetTheme {
|
||||
/** TREK's palette (client/src/mobile/mobile.css) for systems without dynamic colour. */
|
||||
val fallback = ColorProviders(
|
||||
light = lightColorScheme(
|
||||
primary = Color(0xFF101013),
|
||||
onPrimary = Color(0xFFF5F5F7),
|
||||
primaryContainer = Color(0xFFE7E5DF),
|
||||
onPrimaryContainer = Color(0xFF101013),
|
||||
secondary = Color(0xFF4A7DDB),
|
||||
secondaryContainer = Color(0xFFE4EAF7),
|
||||
onSecondaryContainer = Color(0xFF1B2F57),
|
||||
tertiary = Color(0xFF2FA37A),
|
||||
surface = Color(0xFFF7F6F2),
|
||||
onSurface = Color(0xFF101013),
|
||||
onSurfaceVariant = Color(0xFF68686F),
|
||||
surfaceVariant = Color(0xFFECEAE4),
|
||||
outline = Color(0xFF9A9AA1),
|
||||
background = Color(0xFFF1EFEA),
|
||||
error = Color(0xFFD6273B),
|
||||
),
|
||||
dark = darkColorScheme(
|
||||
primary = Color(0xFFF5F5F7),
|
||||
onPrimary = Color(0xFF101013),
|
||||
primaryContainer = Color(0xFF26262C),
|
||||
onPrimaryContainer = Color(0xFFF5F5F7),
|
||||
secondary = Color(0xFF8AAEF0),
|
||||
secondaryContainer = Color(0xFF233250),
|
||||
onSecondaryContainer = Color(0xFFDCE6FA),
|
||||
tertiary = Color(0xFF5CC79E),
|
||||
surface = Color(0xFF121216),
|
||||
onSurface = Color(0xFFF5F5F7),
|
||||
onSurfaceVariant = Color(0xFF9C9CA3),
|
||||
surfaceVariant = Color(0xFF1E1E24),
|
||||
outline = Color(0xFF6E6E76),
|
||||
background = Color(0xFF0C0C0F),
|
||||
error = Color(0xFFFF6B7A),
|
||||
),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun Themed(content: @Composable () -> Unit) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) GlanceTheme(content = content) else GlanceTheme(colors = fallback, content = content)
|
||||
}
|
||||
}
|
||||
|
||||
/** The status colours, the same on every wallpaper so they keep their meaning. */
|
||||
object TrekStatus {
|
||||
val confirmed = ColorProvider(Color(0xFF2FA37A))
|
||||
val pending = ColorProvider(Color(0xFFE8A13A))
|
||||
val danger = ColorProvider(Color(0xFFD6273B))
|
||||
val white = ColorProvider(Color.White)
|
||||
val whiteMuted = ColorProvider(Color(0xCCFFFFFF))
|
||||
val whiteFaint = ColorProvider(Color(0x99FFFFFF))
|
||||
val scrim = ColorProvider(Color(0x66000000))
|
||||
val glass = ColorProvider(Color(0x33000000))
|
||||
}
|
||||
|
||||
/** Opens [path] of the user's server in the app (nativeApp.ts routes com.liketrek.trek://navigate). */
|
||||
fun openPath(context: Context, path: String): Action = actionStartActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse(WidgetSnapshot.link(path)), context, MainActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP),
|
||||
)
|
||||
|
||||
/** The ground of a widget: the system's widget shape and the theme's widget background. */
|
||||
@Composable
|
||||
fun WidgetFrame(
|
||||
onClick: Action?,
|
||||
background: ColorProvider = GlanceTheme.colors.widgetBackground,
|
||||
padding: Dp = 14.dp,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
var modifier = GlanceModifier.fillMaxSize().appWidgetBackground().background(background).padding(padding)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) modifier = modifier.cornerRadius(android.R.dimen.system_app_widget_background_radius)
|
||||
if (onClick != null) modifier = modifier.clickable(onClick)
|
||||
Box(modifier = modifier) { content() }
|
||||
}
|
||||
|
||||
/** A small, bold, upper-case line above a widget's content. */
|
||||
@Composable
|
||||
@GlanceComposable
|
||||
fun Eyebrow(text: String, color: ColorProvider = GlanceTheme.colors.onSurfaceVariant, modifier: GlanceModifier = GlanceModifier) {
|
||||
Text(
|
||||
text = text.uppercase(Locale.getDefault()),
|
||||
style = TextStyle(color = color, fontSize = 11.sp, fontWeight = FontWeight.Bold),
|
||||
maxLines = 1,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Line(
|
||||
text: String,
|
||||
size: TextUnit = 13.sp,
|
||||
color: ColorProvider = GlanceTheme.colors.onSurface,
|
||||
weight: FontWeight = FontWeight.Normal,
|
||||
maxLines: Int = 1,
|
||||
modifier: GlanceModifier = GlanceModifier,
|
||||
) {
|
||||
Text(text = text, style = TextStyle(color = color, fontSize = size, fontWeight = weight), maxLines = maxLines, modifier = modifier)
|
||||
}
|
||||
|
||||
/** An icon in a tinted circle: a booking's kind, a stop's category. */
|
||||
@Composable
|
||||
fun IconBadge(symbol: String?, tint: ColorProvider, background: ColorProvider, size: Dp = 28.dp) {
|
||||
Box(
|
||||
modifier = GlanceModifier.size(size).background(background).cornerRadius(size / 2),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
provider = TrekIcons.provider(symbol),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(tint),
|
||||
modifier = GlanceModifier.size(size * 0.58f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Icon(vector: ImageVector, tint: ColorProvider, size: Dp = 14.dp) {
|
||||
Image(provider = TrekIcons.provider(vector), contentDescription = null, colorFilter = ColorFilter.tint(tint), modifier = GlanceModifier.size(size))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SymbolIcon(symbol: String, tint: ColorProvider, size: Dp = 14.dp) {
|
||||
Image(provider = TrekIcons.provider(symbol), contentDescription = null, colorFilter = ColorFilter.tint(tint), modifier = GlanceModifier.size(size))
|
||||
}
|
||||
|
||||
/** A rounded pill with a short text: a date range, a weather reading, a status. */
|
||||
@Composable
|
||||
fun Chip(
|
||||
text: String,
|
||||
background: ColorProvider,
|
||||
color: ColorProvider,
|
||||
leading: ImageProvider? = null,
|
||||
leadingTint: ColorProvider? = null,
|
||||
) {
|
||||
Row(
|
||||
modifier = GlanceModifier.background(background).cornerRadius(100.dp).padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (leading != null) {
|
||||
Image(
|
||||
provider = leading,
|
||||
contentDescription = null,
|
||||
colorFilter = leadingTint?.let { ColorFilter.tint(it) },
|
||||
modifier = GlanceModifier.size(12.dp),
|
||||
)
|
||||
Spacer(GlanceModifier.width(4.dp))
|
||||
}
|
||||
Text(text = text, style = TextStyle(color = color, fontSize = 11.sp, fontWeight = FontWeight.Medium), maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WeatherChip(weather: WidgetSnapshot.Weather, onPhoto: Boolean) {
|
||||
Chip(
|
||||
text = "${weather.temp}°",
|
||||
background = if (onPhoto) TrekStatus.glass else GlanceTheme.colors.secondaryContainer,
|
||||
color = if (onPhoto) TrekStatus.white else GlanceTheme.colors.onSecondaryContainer,
|
||||
leading = TrekIcons.provider(TrekIcons.weather(weather.main)),
|
||||
leadingTint = if (onPhoto) TrekStatus.white else GlanceTheme.colors.onSecondaryContainer,
|
||||
)
|
||||
}
|
||||
|
||||
/** A horizontal bar of [value] (0..1): Glance's progress indicator, rounded. */
|
||||
@Composable
|
||||
fun Bar(value: Float, color: ColorProvider, track: ColorProvider, height: Dp = 6.dp, modifier: GlanceModifier = GlanceModifier.fillMaxWidth()) {
|
||||
androidx.glance.appwidget.LinearProgressIndicator(
|
||||
progress = value.coerceIn(0f, 1f),
|
||||
color = color,
|
||||
backgroundColor = track,
|
||||
modifier = modifier.height(height).cornerRadius(height / 2),
|
||||
)
|
||||
}
|
||||
|
||||
/** The day segments of a trip: one bar per day, filled up to today. Drawn as one image (a Row takes ten children at most). */
|
||||
@Composable
|
||||
fun DaySegments(index: Int, total: Int, color: ColorProvider) {
|
||||
Image(
|
||||
provider = ImageProvider(WidgetArt.segments(index, total)),
|
||||
contentDescription = "$index / $total",
|
||||
colorFilter = ColorFilter.tint(color),
|
||||
contentScale = androidx.glance.layout.ContentScale.FillBounds,
|
||||
modifier = GlanceModifier.fillMaxWidth().height(4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** Relative days and ranges in the device's language. */
|
||||
object RelativeDay {
|
||||
fun label(iso: String?, labels: WidgetSnapshot.Labels, today: LocalDate = LocalDate.now(), locale: Locale = Locale.getDefault()): String? {
|
||||
val date = WidgetSnapshot.day(iso) ?: return null
|
||||
val days = ChronoUnit.DAYS.between(today, date)
|
||||
if (days == 0L) return labels.todayShort
|
||||
if (days == 1L) return labels.tomorrowShort
|
||||
val skeleton = if (days in 2..6) "EEE" else "EEEdMMM"
|
||||
return DateTimeFormatter.ofPattern(DateFormat.getBestDateTimePattern(locale, skeleton), locale).format(date)
|
||||
}
|
||||
|
||||
/** "1-9 Oct" in the device's own way of writing a range. */
|
||||
fun range(context: Context, start: String, end: String): String? {
|
||||
val from = WidgetSnapshot.day(start) ?: return null
|
||||
val to = WidgetSnapshot.day(end) ?: return null
|
||||
val zone = java.time.ZoneId.systemDefault()
|
||||
val startMs = from.atStartOfDay(zone).toInstant().toEpochMilli()
|
||||
// formatDateRange's end is exclusive: noon of the last day keeps it in the range.
|
||||
val endMs = to.atTime(12, 0).atZone(zone).toInstant().toEpochMilli()
|
||||
val flags = android.text.format.DateUtils.FORMAT_SHOW_DATE or android.text.format.DateUtils.FORMAT_ABBREV_MONTH or
|
||||
android.text.format.DateUtils.FORMAT_NO_YEAR
|
||||
return android.text.format.DateUtils.formatDateRange(context, startMs, endMs, flags)
|
||||
}
|
||||
}
|
||||
|
||||
/** Minutes after midnight, now. */
|
||||
fun nowMinutes(): Int = java.time.LocalTime.now().let { it.hour * 60 + it.minute }
|
||||
|
||||
/** "#rrggbb" as a colour, null for anything else. */
|
||||
fun cssColor(css: String?): ColorProvider? {
|
||||
val hex = css?.removePrefix("#") ?: return null
|
||||
if (hex.length != 6) return null
|
||||
val value = hex.toLongOrNull(16) ?: return null
|
||||
return ColorProvider(Color(0xFF000000 or value))
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package com.liketrek.trek.widget
|
||||
|
||||
/**
|
||||
* The world as 76 by 34 dots from 80 degrees north to 58 south, each the ISO
|
||||
* code of the country under it, ".." for sea. The same grid as the iOS widget
|
||||
* (client/ios/App/TREKWidgets/WorldGrid.swift, generated from
|
||||
* server/assets/atlas/admin0.geojson.gz); copy it over when that one changes.
|
||||
*/
|
||||
object WorldGrid {
|
||||
const val COLUMNS = 76
|
||||
val ROWS: List<String> = listOf(
|
||||
"........................CACACA..CACACACACACA..GLGLGLGLGLGLGLGLGLGLGL............NONONO..................RU..........RURURU..............................",
|
||||
"......................CACACACACA..CACACACACA........GLGLGLGLGLGLGLGL..............................RURU....RURURURURURURURURURURURURU..RURURU............",
|
||||
"......USUSUSUSUSCACACACACACACACACACACACACACACACA....GLGLGLGLGLGLGL................NONONORURU....RURU..RURURURURURURURURURURURURURURURURURURURURURURURURU",
|
||||
"RURU..USUSUSUSUSCACACACACACACACACACACACACACACACACA....GLGLGLGL....ISIS..........NOSESEFIRURURURURURURURURURURURURURURURURURURURURURURURURURURURURURURURU",
|
||||
"......USUSUSUSUSCACACACACACACACACACACA....CACACACA....GLGL....................NONOSEFIFIRURURURURURURURURURURURURURURURURURURURURURURURURURURURURURURURU",
|
||||
"........USUS......USCACACACACACACACACACA..CACACACA........................GB..NOSESELVLVRURURURURURURURURURURURURURURURURURURURURURURURU......RURU......",
|
||||
"......US............CACACACACACACACACACACACACACACACA....................IEGBGBNLDEPLPLBYBYRURURURURURUKZKZRURURURURURURURURURURURURURURURU....RU........",
|
||||
"......................CACACACACACACACACACACACACACACACA....................FRFRDEDECZPLUAUAUARURUKZKZKZKZKZKZKZKZRUMNMNMNMNRUMNCNCNRURURURU..............",
|
||||
"........................USUSUSUSUSUSUSUSCACACAUSCA........................FRFRITITHRROROMDUARURURUKZKZKZKZKZKZCNCNMNMNMNMNMNCNCNCNCNRU..................",
|
||||
"......................USUSUSUSUSUSUSUSUSUSUSUS..........................ESESES..ITITALBGTRTRTRGEAZTMTMUZKZKGKGCNCNCNCNCNCNCNCNCNCNCN..JPJP..............",
|
||||
"........................USUSUSUSUSUSUSUSUSUS............................ESESDZDZ....GRTRTRTRTRIRIRIRIRTMTJTJCNCNCNCNCNCNCNCNCNCNKPKR..JP................",
|
||||
"..........................USUSUSUSUSUSUSUSUS............................MAMADZDZTN..LY....LBIQIQIRIRIRAFAFPKINCNCNCNCNCNCNCNCNCNKRJPJP..................",
|
||||
"..........................MXMXMXMXUSUS..US............................MADZDZDZDZLYLYLYEGEGSASAIQIRIRIRPKPKININNPCNCNCNCNCNCNCNCN........................",
|
||||
"............................MXMXMXUS....US..........................EHEHMRDZDZDZLYLYLYEGEGSASASASAAEIRPKPKINININININMMCNCNCNCNCN........................",
|
||||
"..............................MXMXMX..MXCUCUBS......................MRMRMRMLDZDZNETDLYSDSDSDSASASASAOM..INININININMMMMLAVNCN............................",
|
||||
"................................MXMXMXMX..JM........................MRMRMRMLMLNENETDTDSDSDSDERYEYEOM......INININ....MMTHVN....PH........................",
|
||||
"....................................GTSVNI....VE....................SNSNMLBFNENENGTDTDSDSDSDETYEYE........ININ......MMTHKH....PH........................",
|
||||
"........................................CRPACOVEVEVE..................SLCIGHBJNGNGTDCFSSSSETETSOSO..........IN....IN....VN......PH......................",
|
||||
"..........................................COCOCOVEGYSR..................CICI..NGCMCFCFCDSSKEETSOSO..................IDMY....MY..ID......................",
|
||||
"..........................................ECCOBRBRBRBRBR........................GACGCDCDUGKEKESO......................ID..IDIDID........................",
|
||||
"..........................................ECPEBRBRBRBRBRBRBR....................CGCDCDCDBITZKE........................IDIDIDIDID..IDIDIDPG..PG..........",
|
||||
"..........................................PEBRBRBRBRBRBRBRBRBR..................AOAOCDCDTZTZTZ..........................IDIDID........IDPGPGPG..........",
|
||||
"..........................................PEPEBOBRBRBRBRBRBRBR....................AOAOCDZMTZMZKM..................................AUAU....PG............",
|
||||
"............................................PEBOBOBRBRBRBRBR....................AOAOAOZMMZMZMZMGMG..............................AUAUAU..AU..............",
|
||||
"..............................................CLBOPYBRBRBRBR....................NANABWBWZWMZ..MGMG............................AUAUAUAUAUAUAU....FR......",
|
||||
"..............................................CLARPYBRBRBR........................NABWBWZAMZ..MG............................AUAUAUAUAUAUAUAU............",
|
||||
"..............................................ARARARBRBR..........................NAZAZAZA..................................AUAUAUAUAUAUAUAUAU..........",
|
||||
"..............................................ARARARUY............................ZAZAZAZA..................................AUAUAUAUAUAUAUAUAU..........",
|
||||
"............................................CLARARARUY..............................ZA......................................AU........AUAUAU............",
|
||||
"............................................CLARARAR....................................................................................AU............NZ",
|
||||
"............................................CLARAR......................................................................................AU........NZNZ..",
|
||||
"............................................CLAR........................................................................................................",
|
||||
"............................................CLAR........................................................................................................",
|
||||
"..............................................CL........................................................................................................",
|
||||
)
|
||||
|
||||
/** The country code of a dot, null for sea. */
|
||||
fun code(row: Int, column: Int): String? {
|
||||
val code = ROWS[row].substring(column * 2, column * 2 + 2)
|
||||
return if (code == "..") null else code
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 69 KiB |
@@ -1,13 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Status bar icon for notifications: a single-colour map pin, as Android
|
||||
tints it and shows only its shape. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,2C8.13,2 5,5.13 5,9c0,5.25 7,13 7,13s7,-7.75 7,-13c0,-3.87 -3.13,-7 -7,-7zM12,11.5c-1.38,0 -2.5,-1.12 -2.5,-2.5s1.12,-2.5 2.5,-2.5 2.5,1.12 2.5,2.5 -1.12,2.5 -2.5,2.5z" />
|
||||
</vector>
|
||||
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The shade over a trip's cover in its widget: darker towards the text at the bottom. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<gradient
|
||||
android:angle="270"
|
||||
android:startColor="#59000000"
|
||||
android:centerColor="#0D000000"
|
||||
android:endColor="#A6000000" />
|
||||
</shape>
|
||||
@@ -1 +0,0 @@
|
||||
unqualifiedResLocale=en-US
|
||||
@@ -1,57 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
|
||||
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
|
||||
<resources>
|
||||
<string name="widget_trip_name">رحلة</string>
|
||||
<string name="widget_trip_description">عدّ تنازلي لرحلتك القادمة، ثم اليوم وما يليه أثناء السفر.</string>
|
||||
<string name="widget_today_name">اليوم</string>
|
||||
<string name="widget_today_description">خطة اليوم كخط زمني، مع الطقس والمحطة التالية.</string>
|
||||
<string name="widget_bookings_name">الحجوزات</string>
|
||||
<string name="widget_bookings_description">رحلاتك وقطاراتك وفنادقك وحجوزات المطاعم القادمة ومواعيدها.</string>
|
||||
<string name="widget_todos_name">المهام</string>
|
||||
<string name="widget_todos_description">ما تبقى للرحلة، ومدى تقدم تجهيز الأمتعة.</string>
|
||||
<string name="widget_packing_name">قائمة الأمتعة</string>
|
||||
<string name="widget_packing_description">مدى امتلاء الحقائب ووزنها وما الذي ما زال ناقصًا.</string>
|
||||
<string name="widget_atlas_name">خريطة العالم</string>
|
||||
<string name="widget_atlas_description">الدول التي زرتها مضيئة على الخريطة.</string>
|
||||
<string name="widget_collab_name">مجموعة السفر</string>
|
||||
<string name="widget_collab_description">من يرافقك وأحدث الرسائل والتصويت المفتوح.</string>
|
||||
<string name="widget_intent_title">اختر الرحلة</string>
|
||||
<string name="widget_intent_description">الرحلة التي تعرضها هذه الأداة.</string>
|
||||
<string name="widget_intent_trip">الرحلة</string>
|
||||
<string name="widget_intent_auto">تلقائي</string>
|
||||
<string name="widget_intent_auto_hint">نفس رحلة لوحة التحكم</string>
|
||||
<string name="share_title">إضافة إلى TREK</string>
|
||||
<string name="share_cancel">إلغاء</string>
|
||||
<string name="share_close">إغلاق</string>
|
||||
<string name="share_trip">الرحلة</string>
|
||||
<string name="share_no_trips">افتح TREK مرة واحدة وستظهر رحلاتك هنا.</string>
|
||||
<string name="share_hint">يقرأ TREK الحجز ويضيفه إلى الرحلة.</string>
|
||||
<string name="share_later">الاختيار لاحقًا</string>
|
||||
<string name="share_add">إضافة</string>
|
||||
<string name="share_add_to">إضافة إلى %1$s</string>
|
||||
<string name="share_added">تمت الإضافة</string>
|
||||
<string name="share_reading">جارٍ القراءة…</string>
|
||||
<string name="share_too_large">هذا الملف أكبر من أن يُستورد.</string>
|
||||
<string name="share_unsupported">لا يستطيع TREK استيراد هذا.</string>
|
||||
<string name="share_failed">تعذّر تمرير هذا إلى TREK. حاول مرة أخرى.</string>
|
||||
<string name="share_now">الآن</string>
|
||||
<string name="share_past">انتهت</string>
|
||||
<string name="share_tomorrow">غدًا</string>
|
||||
<string name="share_in_days">بعد %1$d يومًا</string>
|
||||
<string name="share_kind_pass">بطاقة Wallet</string>
|
||||
<string name="share_kind_pdf">PDF</string>
|
||||
<string name="share_kind_mail">بريد</string>
|
||||
<string name="share_kind_page">صفحة ويب</string>
|
||||
<string name="share_kind_text">نص</string>
|
||||
<string name="share_kind_image">صورة</string>
|
||||
<string name="share_kind_file">ملف</string>
|
||||
<string name="widget_empty">افتح TREK مرة واحدة وستظهر رحلاتك هنا.</string>
|
||||
<string name="push_channel_chat">دردشة الرحلة</string>
|
||||
<string name="push_channel_trips">تحديثات الرحلة</string>
|
||||
<string name="push_channel_invites">الدعوات والمشاركات</string>
|
||||
<string name="push_channel_name">عام</string>
|
||||
<string name="push_channel_tripday">يوم السفر</string>
|
||||
<string name="push_fallback_body">إشعار جديد</string>
|
||||
<string name="push_channel_tripday_description">يوم الرحلة الجارية ومحطتها التالية.</string>
|
||||
</resources>
|
||||
@@ -1,57 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
|
||||
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
|
||||
<resources>
|
||||
<string name="widget_trip_name">Səyahət</string>
|
||||
<string name="widget_trip_description">Növbəti səyahətə qədər günləri sayır, yolda isə günü və sonrakını göstərir.</string>
|
||||
<string name="widget_today_name">Bu gün</string>
|
||||
<string name="widget_today_description">Günün planı zaman xətti kimi, hava və növbəti dayanacaqla.</string>
|
||||
<string name="widget_bookings_name">Bronlar</string>
|
||||
<string name="widget_bookings_description">Növbəti uçuşlar, qatarlar, otellər və restoranlar, nə vaxt olduqları ilə.</string>
|
||||
<string name="widget_todos_name">Tapşırıqlar</string>
|
||||
<string name="widget_todos_description">Səyahət üçün hələ nəyin qaldığı və yığışmağın nə yerdə olduğu.</string>
|
||||
<string name="widget_packing_name">Yük siyahısı</string>
|
||||
<string name="widget_packing_description">Çantaların doluluğu, çəkisi və hələ nəyin çatışmadığı.</string>
|
||||
<string name="widget_atlas_name">Dünya xəritəsi</string>
|
||||
<string name="widget_atlas_description">Olduğun ölkələr xəritədə parlayır.</string>
|
||||
<string name="widget_collab_name">Səyahət qrupu</string>
|
||||
<string name="widget_collab_description">Kim gəlir, son mesajlar və açıq səsvermə.</string>
|
||||
<string name="widget_intent_title">Səyahəti seç</string>
|
||||
<string name="widget_intent_description">Bu vidcetin göstərdiyi səyahət.</string>
|
||||
<string name="widget_intent_trip">Səyahət</string>
|
||||
<string name="widget_intent_auto">Avtomatik</string>
|
||||
<string name="widget_intent_auto_hint">İdarə panelindəki səyahət</string>
|
||||
<string name="share_title">TREK-ə əlavə et</string>
|
||||
<string name="share_cancel">Ləğv et</string>
|
||||
<string name="share_close">Bağla</string>
|
||||
<string name="share_trip">Səyahət</string>
|
||||
<string name="share_no_trips">TREK-i bir dəfə açın, səyahətləriniz burada görünəcək.</string>
|
||||
<string name="share_hint">TREK rezervasiyanı oxuyur və səyahətə əlavə edir.</string>
|
||||
<string name="share_later">Sonra seç</string>
|
||||
<string name="share_add">Əlavə et</string>
|
||||
<string name="share_add_to">%1$s səyahətinə əlavə et</string>
|
||||
<string name="share_added">Əlavə edildi</string>
|
||||
<string name="share_reading">Oxunur…</string>
|
||||
<string name="share_too_large">Bu fayl idxal üçün çox böyükdür.</string>
|
||||
<string name="share_unsupported">TREK bunu idxal edə bilmir.</string>
|
||||
<string name="share_failed">Bunu TREK-ə ötürmək alınmadı. Yenidən cəhd edin.</string>
|
||||
<string name="share_now">İndi</string>
|
||||
<string name="share_past">Keçib</string>
|
||||
<string name="share_tomorrow">Sabah</string>
|
||||
<string name="share_in_days">%1$d gün sonra</string>
|
||||
<string name="share_kind_pass">Wallet kartı</string>
|
||||
<string name="share_kind_pdf">PDF</string>
|
||||
<string name="share_kind_mail">E-poçt</string>
|
||||
<string name="share_kind_page">Veb səhifə</string>
|
||||
<string name="share_kind_text">Mətn</string>
|
||||
<string name="share_kind_image">Foto</string>
|
||||
<string name="share_kind_file">Fayl</string>
|
||||
<string name="widget_empty">TREK-i bir dəfə açın, səyahətləriniz burada görünəcək.</string>
|
||||
<string name="push_channel_chat">Səyahət söhbəti</string>
|
||||
<string name="push_channel_trips">Səyahət yenilikləri</string>
|
||||
<string name="push_channel_invites">Dəvətlər və paylaşımlar</string>
|
||||
<string name="push_channel_name">Ümumi</string>
|
||||
<string name="push_channel_tripday">Səyahət günü</string>
|
||||
<string name="push_fallback_body">Yeni bildiriş</string>
|
||||
<string name="push_channel_tripday_description">Davam edən səyahətin günü və növbəti dayanacağı.</string>
|
||||
</resources>
|
||||
@@ -1,57 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
|
||||
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
|
||||
<resources>
|
||||
<string name="widget_trip_name">旅行</string>
|
||||
<string name="widget_trip_description">倒数到下一次旅行,旅途中显示当天行程和接下来的安排。</string>
|
||||
<string name="widget_today_name">今天</string>
|
||||
<string name="widget_today_description">以时间线显示今天的计划,附带天气和下一站。</string>
|
||||
<string name="widget_bookings_name">预订</string>
|
||||
<string name="widget_bookings_description">你接下来的航班、火车、酒店和餐厅,以及时间。</string>
|
||||
<string name="widget_todos_name">待办</string>
|
||||
<string name="widget_todos_description">旅行还剩哪些事没做,以及行李收拾到哪一步。</string>
|
||||
<string name="widget_packing_name">行李</string>
|
||||
<string name="widget_packing_description">包装得多满、重量多少,以及还缺什么。</string>
|
||||
<string name="widget_atlas_name">世界地图</string>
|
||||
<string name="widget_atlas_description">你去过的国家在地图上亮起。</string>
|
||||
<string name="widget_collab_name">旅行小组</string>
|
||||
<string name="widget_collab_description">谁一起去、最新消息和进行中的投票。</string>
|
||||
<string name="widget_intent_title">选择旅行</string>
|
||||
<string name="widget_intent_description">此小组件显示的旅行。</string>
|
||||
<string name="widget_intent_trip">旅行</string>
|
||||
<string name="widget_intent_auto">自动</string>
|
||||
<string name="widget_intent_auto_hint">与仪表板相同的旅行</string>
|
||||
<string name="share_title">添加到 TREK</string>
|
||||
<string name="share_cancel">取消</string>
|
||||
<string name="share_close">关闭</string>
|
||||
<string name="share_trip">行程</string>
|
||||
<string name="share_no_trips">打开一次 TREK,你的行程就会显示在这里。</string>
|
||||
<string name="share_hint">TREK 会读取预订并添加到行程中。</string>
|
||||
<string name="share_later">稍后选择</string>
|
||||
<string name="share_add">添加</string>
|
||||
<string name="share_add_to">添加到 %1$s</string>
|
||||
<string name="share_added">已添加</string>
|
||||
<string name="share_reading">正在读取…</string>
|
||||
<string name="share_too_large">此文件太大,无法导入。</string>
|
||||
<string name="share_unsupported">TREK 无法导入此内容。</string>
|
||||
<string name="share_failed">无法传给 TREK。请重试。</string>
|
||||
<string name="share_now">进行中</string>
|
||||
<string name="share_past">已结束</string>
|
||||
<string name="share_tomorrow">明天</string>
|
||||
<string name="share_in_days">%1$d 天后</string>
|
||||
<string name="share_kind_pass">Wallet 卡券</string>
|
||||
<string name="share_kind_pdf">PDF</string>
|
||||
<string name="share_kind_mail">邮件</string>
|
||||
<string name="share_kind_page">网页</string>
|
||||
<string name="share_kind_text">文本</string>
|
||||
<string name="share_kind_image">照片</string>
|
||||
<string name="share_kind_file">文件</string>
|
||||
<string name="widget_empty">打开一次 TREK,你的行程就会显示在这里。</string>
|
||||
<string name="push_channel_chat">行程聊天</string>
|
||||
<string name="push_channel_trips">行程动态</string>
|
||||
<string name="push_channel_invites">邀请与共享</string>
|
||||
<string name="push_channel_name">常规</string>
|
||||
<string name="push_channel_tripday">旅行日</string>
|
||||
<string name="push_fallback_body">新通知</string>
|
||||
<string name="push_channel_tripday_description">进行中行程的当天安排和下一站。</string>
|
||||
</resources>
|
||||
@@ -1,57 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
|
||||
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
|
||||
<resources>
|
||||
<string name="widget_trip_name">旅行</string>
|
||||
<string name="widget_trip_description">倒數到下一次旅行,旅途中顯示當天行程與接下來的安排。</string>
|
||||
<string name="widget_today_name">今天</string>
|
||||
<string name="widget_today_description">以時間軸顯示今天的計畫,附上天氣與下一站。</string>
|
||||
<string name="widget_bookings_name">預訂</string>
|
||||
<string name="widget_bookings_description">你接下來的航班、火車、飯店與餐廳,以及時間。</string>
|
||||
<string name="widget_todos_name">待辦</string>
|
||||
<string name="widget_todos_description">旅行還剩哪些事沒做,以及行李整理到哪一步。</string>
|
||||
<string name="widget_packing_name">行李</string>
|
||||
<string name="widget_packing_description">包包裝得多滿、重量多少,以及還缺什麼。</string>
|
||||
<string name="widget_atlas_name">世界地圖</string>
|
||||
<string name="widget_atlas_description">你去過的國家在地圖上亮起。</string>
|
||||
<string name="widget_collab_name">旅行小組</string>
|
||||
<string name="widget_collab_description">誰一起去、最新訊息與進行中的投票。</string>
|
||||
<string name="widget_intent_title">選擇旅行</string>
|
||||
<string name="widget_intent_description">此小工具顯示的旅行。</string>
|
||||
<string name="widget_intent_trip">旅行</string>
|
||||
<string name="widget_intent_auto">自動</string>
|
||||
<string name="widget_intent_auto_hint">與儀表板相同的旅行</string>
|
||||
<string name="share_title">加入 TREK</string>
|
||||
<string name="share_cancel">取消</string>
|
||||
<string name="share_close">關閉</string>
|
||||
<string name="share_trip">行程</string>
|
||||
<string name="share_no_trips">開啟一次 TREK,你的行程就會顯示在這裡。</string>
|
||||
<string name="share_hint">TREK 會讀取訂位並加入行程。</string>
|
||||
<string name="share_later">稍後選擇</string>
|
||||
<string name="share_add">加入</string>
|
||||
<string name="share_add_to">加入 %1$s</string>
|
||||
<string name="share_added">已加入</string>
|
||||
<string name="share_reading">正在讀取…</string>
|
||||
<string name="share_too_large">此檔案太大,無法匯入。</string>
|
||||
<string name="share_unsupported">TREK 無法匯入此內容。</string>
|
||||
<string name="share_failed">無法傳給 TREK。請重試。</string>
|
||||
<string name="share_now">進行中</string>
|
||||
<string name="share_past">已結束</string>
|
||||
<string name="share_tomorrow">明天</string>
|
||||
<string name="share_in_days">%1$d 天後</string>
|
||||
<string name="share_kind_pass">Wallet 票卡</string>
|
||||
<string name="share_kind_pdf">PDF</string>
|
||||
<string name="share_kind_mail">郵件</string>
|
||||
<string name="share_kind_page">網頁</string>
|
||||
<string name="share_kind_text">文字</string>
|
||||
<string name="share_kind_image">照片</string>
|
||||
<string name="share_kind_file">檔案</string>
|
||||
<string name="widget_empty">開啟一次 TREK,你的行程就會顯示在這裡。</string>
|
||||
<string name="push_channel_chat">行程聊天</string>
|
||||
<string name="push_channel_trips">行程動態</string>
|
||||
<string name="push_channel_invites">邀請與共享</string>
|
||||
<string name="push_channel_name">一般</string>
|
||||
<string name="push_channel_tripday">旅行日</string>
|
||||
<string name="push_fallback_body">新通知</string>
|
||||
<string name="push_channel_tripday_description">進行中行程的當天安排與下一站。</string>
|
||||
</resources>
|
||||
@@ -1,57 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
|
||||
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
|
||||
<resources>
|
||||
<string name="widget_trip_name">Viatge</string>
|
||||
<string name="widget_trip_description">Compta els dies fins al proper viatge i, durant el viatge, mostra el dia i el que ve després.</string>
|
||||
<string name="widget_today_name">Avui</string>
|
||||
<string name="widget_today_description">El pla del dia com a línia de temps, amb el temps i la propera parada.</string>
|
||||
<string name="widget_bookings_name">Reserves</string>
|
||||
<string name="widget_bookings_description">Els teus propers vols, trens, hotels i taules, i quan toquen.</string>
|
||||
<string name="widget_todos_name">Tasques</string>
|
||||
<string name="widget_todos_description">El que encara queda per al viatge i com va l\'equipatge.</string>
|
||||
<string name="widget_packing_name">Equipatge</string>
|
||||
<string name="widget_packing_description">Com de plenes són les maletes, el pes i el que encara falta.</string>
|
||||
<string name="widget_atlas_name">Mapa del món</string>
|
||||
<string name="widget_atlas_description">Els països on has estat, brillant al mapa.</string>
|
||||
<string name="widget_collab_name">Grup de viatge</string>
|
||||
<string name="widget_collab_description">Qui ve, els darrers missatges i la votació oberta.</string>
|
||||
<string name="widget_intent_title">Tria el viatge</string>
|
||||
<string name="widget_intent_description">El viatge que mostra aquest giny.</string>
|
||||
<string name="widget_intent_trip">Viatge</string>
|
||||
<string name="widget_intent_auto">Automàtic</string>
|
||||
<string name="widget_intent_auto_hint">El mateix viatge que el tauler</string>
|
||||
<string name="share_title">Afegeix a TREK</string>
|
||||
<string name="share_cancel">Cancel·la</string>
|
||||
<string name="share_close">Tanca</string>
|
||||
<string name="share_trip">Viatge</string>
|
||||
<string name="share_no_trips">Obre TREK una vegada i aquí apareixeran els teus viatges.</string>
|
||||
<string name="share_hint">TREK llegeix la reserva i l’afegeix al viatge.</string>
|
||||
<string name="share_later">Tria més tard</string>
|
||||
<string name="share_add">Afegeix</string>
|
||||
<string name="share_add_to">Afegeix a %1$s</string>
|
||||
<string name="share_added">Afegit</string>
|
||||
<string name="share_reading">Llegint…</string>
|
||||
<string name="share_too_large">Aquest fitxer és massa gran per importar-lo.</string>
|
||||
<string name="share_unsupported">TREK no pot importar això.</string>
|
||||
<string name="share_failed">No s’ha pogut passar a TREK. Torna-ho a provar.</string>
|
||||
<string name="share_now">Ara</string>
|
||||
<string name="share_past">Passat</string>
|
||||
<string name="share_tomorrow">Demà</string>
|
||||
<string name="share_in_days">D’aquí a %1$d dies</string>
|
||||
<string name="share_kind_pass">Passi de Wallet</string>
|
||||
<string name="share_kind_pdf">PDF</string>
|
||||
<string name="share_kind_mail">Correu</string>
|
||||
<string name="share_kind_page">Pàgina web</string>
|
||||
<string name="share_kind_text">Text</string>
|
||||
<string name="share_kind_image">Foto</string>
|
||||
<string name="share_kind_file">Fitxer</string>
|
||||
<string name="widget_empty">Obre TREK una vegada i aquí apareixeran els teus viatges.</string>
|
||||
<string name="push_channel_chat">Xat del viatge</string>
|
||||
<string name="push_channel_trips">Novetats del viatge</string>
|
||||
<string name="push_channel_invites">Invitacions i comparticions</string>
|
||||
<string name="push_channel_name">General</string>
|
||||
<string name="push_channel_tripday">Dia de viatge</string>
|
||||
<string name="push_fallback_body">Notificació nova</string>
|
||||
<string name="push_channel_tripday_description">El dia d\'un viatge en curs i la seva propera parada.</string>
|
||||
</resources>
|
||||
@@ -1,57 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
|
||||
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
|
||||
<resources>
|
||||
<string name="widget_trip_name">Cesta</string>
|
||||
<string name="widget_trip_description">Odpočítává dny do další cesty a během ní ukazuje den a co přijde dál.</string>
|
||||
<string name="widget_today_name">Dnes</string>
|
||||
<string name="widget_today_description">Plán dne jako časová osa, s počasím a další zastávkou.</string>
|
||||
<string name="widget_bookings_name">Rezervace</string>
|
||||
<string name="widget_bookings_description">Tvoje další lety, vlaky, hotely a stoly, a kdy tě čekají.</string>
|
||||
<string name="widget_todos_name">Úkoly</string>
|
||||
<string name="widget_todos_description">Co ještě zbývá k cestě a jak daleko je balení.</string>
|
||||
<string name="widget_packing_name">Balení</string>
|
||||
<string name="widget_packing_description">Jak plná jsou zavazadla, jejich váha a co ještě chybí.</string>
|
||||
<string name="widget_atlas_name">Mapa světa</string>
|
||||
<string name="widget_atlas_description">Země, kde jsi byl, zářící na mapě.</string>
|
||||
<string name="widget_collab_name">Cestovní skupina</string>
|
||||
<string name="widget_collab_description">Kdo jede s tebou, nejnovější zprávy a otevřené hlasování.</string>
|
||||
<string name="widget_intent_title">Vybrat cestu</string>
|
||||
<string name="widget_intent_description">Cesta, kterou tento widget zobrazuje.</string>
|
||||
<string name="widget_intent_trip">Cesta</string>
|
||||
<string name="widget_intent_auto">Automaticky</string>
|
||||
<string name="widget_intent_auto_hint">Stejná cesta jako na nástěnce</string>
|
||||
<string name="share_title">Přidat do TREK</string>
|
||||
<string name="share_cancel">Zrušit</string>
|
||||
<string name="share_close">Zavřít</string>
|
||||
<string name="share_trip">Cesta</string>
|
||||
<string name="share_no_trips">Jednou otevřete TREK a vaše cesty se zobrazí zde.</string>
|
||||
<string name="share_hint">TREK přečte rezervaci a přidá ji k cestě.</string>
|
||||
<string name="share_later">Vybrat později</string>
|
||||
<string name="share_add">Přidat</string>
|
||||
<string name="share_add_to">Přidat do %1$s</string>
|
||||
<string name="share_added">Přidáno</string>
|
||||
<string name="share_reading">Načítání…</string>
|
||||
<string name="share_too_large">Tento soubor je pro import příliš velký.</string>
|
||||
<string name="share_unsupported">Tohle TREK neumí importovat.</string>
|
||||
<string name="share_failed">Nepodařilo se to předat do TREK. Zkuste to znovu.</string>
|
||||
<string name="share_now">Teď</string>
|
||||
<string name="share_past">Proběhla</string>
|
||||
<string name="share_tomorrow">Zítra</string>
|
||||
<string name="share_in_days">Za %1$d dní</string>
|
||||
<string name="share_kind_pass">Karta Wallet</string>
|
||||
<string name="share_kind_pdf">PDF</string>
|
||||
<string name="share_kind_mail">E-mail</string>
|
||||
<string name="share_kind_page">Webová stránka</string>
|
||||
<string name="share_kind_text">Text</string>
|
||||
<string name="share_kind_image">Fotka</string>
|
||||
<string name="share_kind_file">Soubor</string>
|
||||
<string name="widget_empty">Jednou otevřete TREK a vaše cesty se zobrazí zde.</string>
|
||||
<string name="push_channel_chat">Chat výletu</string>
|
||||
<string name="push_channel_trips">Novinky z výletu</string>
|
||||
<string name="push_channel_invites">Pozvánky a sdílení</string>
|
||||
<string name="push_channel_name">Obecné</string>
|
||||
<string name="push_channel_tripday">Cestovní den</string>
|
||||
<string name="push_fallback_body">Nové oznámení</string>
|
||||
<string name="push_channel_tripday_description">Den probíhajícího výletu a jeho další zastávka.</string>
|
||||
</resources>
|
||||
@@ -1,57 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
|
||||
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
|
||||
<resources>
|
||||
<string name="widget_trip_name">Reise</string>
|
||||
<string name="widget_trip_description">Zählt die Tage bis zur nächsten Reise und zeigt unterwegs den Tag und was als Nächstes kommt.</string>
|
||||
<string name="widget_today_name">Heute</string>
|
||||
<string name="widget_today_description">Der Tagesplan als Zeitleiste, mit dem Wetter und dem nächsten Stopp.</string>
|
||||
<string name="widget_bookings_name">Buchungen</string>
|
||||
<string name="widget_bookings_description">Deine nächsten Flüge, Züge, Hotels und Tische, und wann sie anstehen.</string>
|
||||
<string name="widget_todos_name">To-dos</string>
|
||||
<string name="widget_todos_description">Was für die Reise noch offen ist und wie weit das Packen ist.</string>
|
||||
<string name="widget_packing_name">Packliste</string>
|
||||
<string name="widget_packing_description">Wie voll die Taschen sind, ihr Gewicht und was noch fehlt.</string>
|
||||
<string name="widget_atlas_name">Weltkarte</string>
|
||||
<string name="widget_atlas_description">Die Länder, in denen du warst, leuchtend auf der Karte.</string>
|
||||
<string name="widget_collab_name">Reisegruppe</string>
|
||||
<string name="widget_collab_description">Wer mitreist, die neuesten Nachrichten und die offene Abstimmung.</string>
|
||||
<string name="widget_intent_title">Reise wählen</string>
|
||||
<string name="widget_intent_description">Die Reise, die dieses Widget zeigt.</string>
|
||||
<string name="widget_intent_trip">Reise</string>
|
||||
<string name="widget_intent_auto">Automatisch</string>
|
||||
<string name="widget_intent_auto_hint">Dieselbe Reise wie im Dashboard</string>
|
||||
<string name="share_title">Zu TREK hinzufügen</string>
|
||||
<string name="share_cancel">Abbrechen</string>
|
||||
<string name="share_close">Schließen</string>
|
||||
<string name="share_trip">Reise</string>
|
||||
<string name="share_no_trips">Öffne TREK einmal, dann erscheinen hier deine Reisen.</string>
|
||||
<string name="share_hint">TREK liest die Buchung und trägt sie in die Reise ein.</string>
|
||||
<string name="share_later">Später wählen</string>
|
||||
<string name="share_add">Hinzufügen</string>
|
||||
<string name="share_add_to">Zu %1$s hinzufügen</string>
|
||||
<string name="share_added">Hinzugefügt</string>
|
||||
<string name="share_reading">Wird gelesen…</string>
|
||||
<string name="share_too_large">Diese Datei ist zu groß für den Import.</string>
|
||||
<string name="share_unsupported">Das kann TREK nicht importieren.</string>
|
||||
<string name="share_failed">Das konnte nicht an TREK übergeben werden. Bitte versuch es erneut.</string>
|
||||
<string name="share_now">Läuft</string>
|
||||
<string name="share_past">Vorbei</string>
|
||||
<string name="share_tomorrow">Morgen</string>
|
||||
<string name="share_in_days">In %1$d Tagen</string>
|
||||
<string name="share_kind_pass">Wallet-Pass</string>
|
||||
<string name="share_kind_pdf">PDF</string>
|
||||
<string name="share_kind_mail">E-Mail</string>
|
||||
<string name="share_kind_page">Webseite</string>
|
||||
<string name="share_kind_text">Text</string>
|
||||
<string name="share_kind_image">Foto</string>
|
||||
<string name="share_kind_file">Datei</string>
|
||||
<string name="widget_empty">Öffne TREK einmal, dann erscheinen hier deine Reisen.</string>
|
||||
<string name="push_channel_chat">Reise-Chat</string>
|
||||
<string name="push_channel_trips">Neues zur Reise</string>
|
||||
<string name="push_channel_invites">Einladungen und Freigaben</string>
|
||||
<string name="push_channel_name">Allgemein</string>
|
||||
<string name="push_channel_tripday">Reisetag</string>
|
||||
<string name="push_fallback_body">Neue Benachrichtigung</string>
|
||||
<string name="push_channel_tripday_description">Der Tag einer laufenden Reise und der nächste Stopp.</string>
|
||||
</resources>
|
||||
@@ -1,57 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
|
||||
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
|
||||
<resources>
|
||||
<string name="widget_trip_name">Ταξίδι</string>
|
||||
<string name="widget_trip_description">Μετρά αντίστροφα ως το επόμενο ταξίδι και στη διάρκειά του δείχνει τη μέρα και τι ακολουθεί.</string>
|
||||
<string name="widget_today_name">Σήμερα</string>
|
||||
<string name="widget_today_description">Το πρόγραμμα της ημέρας ως χρονολόγιο, με τον καιρό και την επόμενη στάση.</string>
|
||||
<string name="widget_bookings_name">Κρατήσεις</string>
|
||||
<string name="widget_bookings_description">Οι επόμενες πτήσεις, τα τρένα, τα ξενοδοχεία και τα τραπέζια σου, και πότε είναι.</string>
|
||||
<string name="widget_todos_name">Εκκρεμότητες</string>
|
||||
<string name="widget_todos_description">Τι μένει ακόμα για το ταξίδι και πόσο έχει προχωρήσει το πακετάρισμα.</string>
|
||||
<string name="widget_packing_name">Αποσκευές</string>
|
||||
<string name="widget_packing_description">Πόσο γεμάτες είναι οι τσάντες, το βάρος τους και τι λείπει ακόμα.</string>
|
||||
<string name="widget_atlas_name">Παγκόσμιος χάρτης</string>
|
||||
<string name="widget_atlas_description">Οι χώρες που έχεις επισκεφθεί, φωτεινές στον χάρτη.</string>
|
||||
<string name="widget_collab_name">Ομάδα ταξιδιού</string>
|
||||
<string name="widget_collab_description">Ποιος έρχεται, τα τελευταία μηνύματα και η ανοιχτή ψηφοφορία.</string>
|
||||
<string name="widget_intent_title">Επιλογή ταξιδιού</string>
|
||||
<string name="widget_intent_description">Το ταξίδι που δείχνει αυτό το widget.</string>
|
||||
<string name="widget_intent_trip">Ταξίδι</string>
|
||||
<string name="widget_intent_auto">Αυτόματα</string>
|
||||
<string name="widget_intent_auto_hint">Το ίδιο ταξίδι με τον πίνακα</string>
|
||||
<string name="share_title">Προσθήκη στο TREK</string>
|
||||
<string name="share_cancel">Ακύρωση</string>
|
||||
<string name="share_close">Κλείσιμο</string>
|
||||
<string name="share_trip">Ταξίδι</string>
|
||||
<string name="share_no_trips">Ανοίξτε το TREK μία φορά και τα ταξίδια σας θα εμφανιστούν εδώ.</string>
|
||||
<string name="share_hint">Το TREK διαβάζει την κράτηση και την προσθέτει στο ταξίδι.</string>
|
||||
<string name="share_later">Επιλογή αργότερα</string>
|
||||
<string name="share_add">Προσθήκη</string>
|
||||
<string name="share_add_to">Προσθήκη στο %1$s</string>
|
||||
<string name="share_added">Προστέθηκε</string>
|
||||
<string name="share_reading">Ανάγνωση…</string>
|
||||
<string name="share_too_large">Το αρχείο είναι πολύ μεγάλο για εισαγωγή.</string>
|
||||
<string name="share_unsupported">Το TREK δεν μπορεί να το εισαγάγει.</string>
|
||||
<string name="share_failed">Δεν ήταν δυνατή η μεταφορά στο TREK. Δοκιμάστε ξανά.</string>
|
||||
<string name="share_now">Τώρα</string>
|
||||
<string name="share_past">Παρελθόν</string>
|
||||
<string name="share_tomorrow">Αύριο</string>
|
||||
<string name="share_in_days">Σε %1$d ημέρες</string>
|
||||
<string name="share_kind_pass">Κάρτα Wallet</string>
|
||||
<string name="share_kind_pdf">PDF</string>
|
||||
<string name="share_kind_mail">Email</string>
|
||||
<string name="share_kind_page">Ιστοσελίδα</string>
|
||||
<string name="share_kind_text">Κείμενο</string>
|
||||
<string name="share_kind_image">Φωτογραφία</string>
|
||||
<string name="share_kind_file">Αρχείο</string>
|
||||
<string name="widget_empty">Ανοίξτε το TREK μία φορά και τα ταξίδια σας θα εμφανιστούν εδώ.</string>
|
||||
<string name="push_channel_chat">Συνομιλία ταξιδιού</string>
|
||||
<string name="push_channel_trips">Ενημερώσεις ταξιδιού</string>
|
||||
<string name="push_channel_invites">Προσκλήσεις και κοινοποιήσεις</string>
|
||||
<string name="push_channel_name">Γενικά</string>
|
||||
<string name="push_channel_tripday">Ημέρα ταξιδιού</string>
|
||||
<string name="push_fallback_body">Νέα ειδοποίηση</string>
|
||||
<string name="push_channel_tripday_description">Η ημέρα ενός ταξιδιού σε εξέλιξη και η επόμενη στάση του.</string>
|
||||
</resources>
|
||||
@@ -1,57 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
|
||||
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
|
||||
<resources>
|
||||
<string name="widget_trip_name">Viaje</string>
|
||||
<string name="widget_trip_description">Cuenta los días hasta tu próximo viaje y, durante él, muestra el día y lo que sigue.</string>
|
||||
<string name="widget_today_name">Hoy</string>
|
||||
<string name="widget_today_description">El plan del día como línea de tiempo, con el tiempo y la próxima parada.</string>
|
||||
<string name="widget_bookings_name">Reservas</string>
|
||||
<string name="widget_bookings_description">Tus próximos vuelos, trenes, hoteles y mesas, y cuándo tocan.</string>
|
||||
<string name="widget_todos_name">Tareas</string>
|
||||
<string name="widget_todos_description">Lo que aún queda para el viaje y cómo va la maleta.</string>
|
||||
<string name="widget_packing_name">Equipaje</string>
|
||||
<string name="widget_packing_description">Lo llenas que van las maletas, su peso y lo que aún falta.</string>
|
||||
<string name="widget_atlas_name">Mapa del mundo</string>
|
||||
<string name="widget_atlas_description">Los países donde has estado, brillando en el mapa.</string>
|
||||
<string name="widget_collab_name">Grupo de viaje</string>
|
||||
<string name="widget_collab_description">Quién viene, los últimos mensajes y la votación abierta.</string>
|
||||
<string name="widget_intent_title">Elegir viaje</string>
|
||||
<string name="widget_intent_description">El viaje que muestra este widget.</string>
|
||||
<string name="widget_intent_trip">Viaje</string>
|
||||
<string name="widget_intent_auto">Automático</string>
|
||||
<string name="widget_intent_auto_hint">El mismo viaje que el panel</string>
|
||||
<string name="share_title">Añadir a TREK</string>
|
||||
<string name="share_cancel">Cancelar</string>
|
||||
<string name="share_close">Cerrar</string>
|
||||
<string name="share_trip">Viaje</string>
|
||||
<string name="share_no_trips">Abre TREK una vez y tus viajes aparecerán aquí.</string>
|
||||
<string name="share_hint">TREK lee la reserva y la añade al viaje.</string>
|
||||
<string name="share_later">Elegir después</string>
|
||||
<string name="share_add">Añadir</string>
|
||||
<string name="share_add_to">Añadir a %1$s</string>
|
||||
<string name="share_added">Añadido</string>
|
||||
<string name="share_reading">Leyendo…</string>
|
||||
<string name="share_too_large">Este archivo es demasiado grande para importarlo.</string>
|
||||
<string name="share_unsupported">TREK no puede importar esto.</string>
|
||||
<string name="share_failed">No se pudo pasar a TREK. Inténtalo de nuevo.</string>
|
||||
<string name="share_now">En curso</string>
|
||||
<string name="share_past">Pasado</string>
|
||||
<string name="share_tomorrow">Mañana</string>
|
||||
<string name="share_in_days">En %1$d días</string>
|
||||
<string name="share_kind_pass">Pase de Wallet</string>
|
||||
<string name="share_kind_pdf">PDF</string>
|
||||
<string name="share_kind_mail">Correo</string>
|
||||
<string name="share_kind_page">Página web</string>
|
||||
<string name="share_kind_text">Texto</string>
|
||||
<string name="share_kind_image">Foto</string>
|
||||
<string name="share_kind_file">Archivo</string>
|
||||
<string name="widget_empty">Abre TREK una vez y tus viajes aparecerán aquí.</string>
|
||||
<string name="push_channel_chat">Chat del viaje</string>
|
||||
<string name="push_channel_trips">Novedades del viaje</string>
|
||||
<string name="push_channel_invites">Invitaciones y compartidos</string>
|
||||
<string name="push_channel_name">General</string>
|
||||
<string name="push_channel_tripday">Día de viaje</string>
|
||||
<string name="push_fallback_body">Notificación nueva</string>
|
||||
<string name="push_channel_tripday_description">El día de un viaje en curso y su próxima parada.</string>
|
||||
</resources>
|
||||
@@ -1,57 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
|
||||
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
|
||||
<resources>
|
||||
<string name="widget_trip_name">Reis</string>
|
||||
<string name="widget_trip_description">Loeb päevi järgmise reisini ja näitab reisi ajal päeva ning seda, mis järgmisena tuleb.</string>
|
||||
<string name="widget_today_name">Täna</string>
|
||||
<string name="widget_today_description">Päeva plaan ajajoonena, koos ilma ja järgmise peatusega.</string>
|
||||
<string name="widget_bookings_name">Broneeringud</string>
|
||||
<string name="widget_bookings_description">Sinu järgmised lennud, rongid, hotellid ja lauad ning millal need on.</string>
|
||||
<string name="widget_todos_name">Ülesanded</string>
|
||||
<string name="widget_todos_description">Mis reisi jaoks veel tegemata on ja kui kaugel pakkimine on.</string>
|
||||
<string name="widget_packing_name">Pakkimine</string>
|
||||
<string name="widget_packing_description">Kui täis kotid on, nende kaal ja mis veel puudu on.</string>
|
||||
<string name="widget_atlas_name">Maailmakaart</string>
|
||||
<string name="widget_atlas_description">Riigid, kus oled käinud, kaardil helendamas.</string>
|
||||
<string name="widget_collab_name">Reisiseltskond</string>
|
||||
<string name="widget_collab_description">Kes tuleb kaasa, viimased sõnumid ja avatud hääletus.</string>
|
||||
<string name="widget_intent_title">Vali reis</string>
|
||||
<string name="widget_intent_description">Reis, mida see vidin näitab.</string>
|
||||
<string name="widget_intent_trip">Reis</string>
|
||||
<string name="widget_intent_auto">Automaatne</string>
|
||||
<string name="widget_intent_auto_hint">Sama reis mis töölaual</string>
|
||||
<string name="share_title">Lisa TREK-i</string>
|
||||
<string name="share_cancel">Tühista</string>
|
||||
<string name="share_close">Sulge</string>
|
||||
<string name="share_trip">Reis</string>
|
||||
<string name="share_no_trips">Ava TREK korra ja sinu reisid ilmuvad siia.</string>
|
||||
<string name="share_hint">TREK loeb broneeringu ja lisab selle reisile.</string>
|
||||
<string name="share_later">Vali hiljem</string>
|
||||
<string name="share_add">Lisa</string>
|
||||
<string name="share_add_to">Lisa reisile %1$s</string>
|
||||
<string name="share_added">Lisatud</string>
|
||||
<string name="share_reading">Loen…</string>
|
||||
<string name="share_too_large">See fail on importimiseks liiga suur.</string>
|
||||
<string name="share_unsupported">TREK ei saa seda importida.</string>
|
||||
<string name="share_failed">Seda ei õnnestunud TREK-ile üle anda. Proovi uuesti.</string>
|
||||
<string name="share_now">Praegu</string>
|
||||
<string name="share_past">Möödas</string>
|
||||
<string name="share_tomorrow">Homme</string>
|
||||
<string name="share_in_days">%1$d päeva pärast</string>
|
||||
<string name="share_kind_pass">Wallet’i pass</string>
|
||||
<string name="share_kind_pdf">PDF</string>
|
||||
<string name="share_kind_mail">E-kiri</string>
|
||||
<string name="share_kind_page">Veebileht</string>
|
||||
<string name="share_kind_text">Tekst</string>
|
||||
<string name="share_kind_image">Foto</string>
|
||||
<string name="share_kind_file">Fail</string>
|
||||
<string name="widget_empty">Ava TREK korra ja sinu reisid ilmuvad siia.</string>
|
||||
<string name="push_channel_chat">Reisi vestlus</string>
|
||||
<string name="push_channel_trips">Reisi uudised</string>
|
||||
<string name="push_channel_invites">Kutsed ja jagamised</string>
|
||||
<string name="push_channel_name">Üldine</string>
|
||||
<string name="push_channel_tripday">Reisipäev</string>
|
||||
<string name="push_fallback_body">Uus teavitus</string>
|
||||
<string name="push_channel_tripday_description">Käimasoleva reisi päev ja järgmine peatus.</string>
|
||||
</resources>
|
||||
@@ -1,57 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
|
||||
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
|
||||
<resources>
|
||||
<string name="widget_trip_name">Voyage</string>
|
||||
<string name="widget_trip_description">Compte les jours jusqu\'au prochain voyage, puis montre la journée et la suite pendant le voyage.</string>
|
||||
<string name="widget_today_name">Aujourd\'hui</string>
|
||||
<string name="widget_today_description">Le programme du jour en frise, avec la météo et la prochaine étape.</string>
|
||||
<string name="widget_bookings_name">Réservations</string>
|
||||
<string name="widget_bookings_description">Vos prochains vols, trains, hôtels et tables, et quand ils arrivent.</string>
|
||||
<string name="widget_todos_name">À faire</string>
|
||||
<string name="widget_todos_description">Ce qui reste à faire pour le voyage et où en sont les bagages.</string>
|
||||
<string name="widget_packing_name">Bagages</string>
|
||||
<string name="widget_packing_description">Le remplissage des sacs, leur poids et ce qui manque encore.</string>
|
||||
<string name="widget_atlas_name">Carte du monde</string>
|
||||
<string name="widget_atlas_description">Les pays où vous êtes allé, illuminés sur la carte.</string>
|
||||
<string name="widget_collab_name">Groupe de voyage</string>
|
||||
<string name="widget_collab_description">Qui vient, les derniers messages et le sondage ouvert.</string>
|
||||
<string name="widget_intent_title">Choisir le voyage</string>
|
||||
<string name="widget_intent_description">Le voyage affiché par ce widget.</string>
|
||||
<string name="widget_intent_trip">Voyage</string>
|
||||
<string name="widget_intent_auto">Automatique</string>
|
||||
<string name="widget_intent_auto_hint">Le même voyage que le tableau de bord</string>
|
||||
<string name="share_title">Ajouter à TREK</string>
|
||||
<string name="share_cancel">Annuler</string>
|
||||
<string name="share_close">Fermer</string>
|
||||
<string name="share_trip">Voyage</string>
|
||||
<string name="share_no_trips">Ouvrez TREK une fois et vos voyages apparaîtront ici.</string>
|
||||
<string name="share_hint">TREK lit la réservation et l’ajoute au voyage.</string>
|
||||
<string name="share_later">Choisir plus tard</string>
|
||||
<string name="share_add">Ajouter</string>
|
||||
<string name="share_add_to">Ajouter à %1$s</string>
|
||||
<string name="share_added">Ajouté</string>
|
||||
<string name="share_reading">Lecture…</string>
|
||||
<string name="share_too_large">Ce fichier est trop volumineux pour être importé.</string>
|
||||
<string name="share_unsupported">TREK ne peut pas importer ceci.</string>
|
||||
<string name="share_failed">Impossible de transmettre ceci à TREK. Réessayez.</string>
|
||||
<string name="share_now">En cours</string>
|
||||
<string name="share_past">Passé</string>
|
||||
<string name="share_tomorrow">Demain</string>
|
||||
<string name="share_in_days">Dans %1$d jours</string>
|
||||
<string name="share_kind_pass">Carte Wallet</string>
|
||||
<string name="share_kind_pdf">PDF</string>
|
||||
<string name="share_kind_mail">E-mail</string>
|
||||
<string name="share_kind_page">Page web</string>
|
||||
<string name="share_kind_text">Texte</string>
|
||||
<string name="share_kind_image">Photo</string>
|
||||
<string name="share_kind_file">Fichier</string>
|
||||
<string name="widget_empty">Ouvrez TREK une fois et vos voyages apparaîtront ici.</string>
|
||||
<string name="push_channel_chat">Discussion du voyage</string>
|
||||
<string name="push_channel_trips">Nouvelles du voyage</string>
|
||||
<string name="push_channel_invites">Invitations et partages</string>
|
||||
<string name="push_channel_name">Général</string>
|
||||
<string name="push_channel_tripday">Jour de voyage</string>
|
||||
<string name="push_fallback_body">Nouvelle notification</string>
|
||||
<string name="push_channel_tripday_description">La journée d\'un voyage en cours et sa prochaine étape.</string>
|
||||
</resources>
|
||||
@@ -1,57 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
|
||||
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
|
||||
<resources>
|
||||
<string name="widget_trip_name">Utazás</string>
|
||||
<string name="widget_trip_description">Visszaszámol a következő utazásig, útközben pedig a napot és a következő programot mutatja.</string>
|
||||
<string name="widget_today_name">Ma</string>
|
||||
<string name="widget_today_description">A nap terve idővonalon, az időjárással és a következő megállóval.</string>
|
||||
<string name="widget_bookings_name">Foglalások</string>
|
||||
<string name="widget_bookings_description">A következő járataid, vonataid, szállásaid és asztalfoglalásaid, és hogy mikor esedékesek.</string>
|
||||
<string name="widget_todos_name">Teendők</string>
|
||||
<string name="widget_todos_description">Ami még hátravan az utazáshoz, és hol tart a csomagolás.</string>
|
||||
<string name="widget_packing_name">Csomagolás</string>
|
||||
<string name="widget_packing_description">Mennyire telik a táska, a súlya, és mi hiányzik még.</string>
|
||||
<string name="widget_atlas_name">Világtérkép</string>
|
||||
<string name="widget_atlas_description">Az országok, ahol jártál, világítva a térképen.</string>
|
||||
<string name="widget_collab_name">Útitársak</string>
|
||||
<string name="widget_collab_description">Ki jön veled, a legújabb üzenetek és a nyitott szavazás.</string>
|
||||
<string name="widget_intent_title">Utazás választása</string>
|
||||
<string name="widget_intent_description">Az utazás, amelyet ez a widget mutat.</string>
|
||||
<string name="widget_intent_trip">Utazás</string>
|
||||
<string name="widget_intent_auto">Automatikus</string>
|
||||
<string name="widget_intent_auto_hint">Ugyanaz az utazás, mint az irányítópulton</string>
|
||||
<string name="share_title">Hozzáadás a TREK-hez</string>
|
||||
<string name="share_cancel">Mégse</string>
|
||||
<string name="share_close">Bezárás</string>
|
||||
<string name="share_trip">Utazás</string>
|
||||
<string name="share_no_trips">Nyisd meg egyszer a TREK-et, és itt megjelennek az utazásaid.</string>
|
||||
<string name="share_hint">A TREK beolvassa a foglalást, és hozzáadja az utazáshoz.</string>
|
||||
<string name="share_later">Később választok</string>
|
||||
<string name="share_add">Hozzáadás</string>
|
||||
<string name="share_add_to">Hozzáadás ehhez: %1$s</string>
|
||||
<string name="share_added">Hozzáadva</string>
|
||||
<string name="share_reading">Olvasás…</string>
|
||||
<string name="share_too_large">Ez a fájl túl nagy az importáláshoz.</string>
|
||||
<string name="share_unsupported">Ezt a TREK nem tudja importálni.</string>
|
||||
<string name="share_failed">Nem sikerült átadni a TREK-nek. Próbáld újra.</string>
|
||||
<string name="share_now">Most</string>
|
||||
<string name="share_past">Elmúlt</string>
|
||||
<string name="share_tomorrow">Holnap</string>
|
||||
<string name="share_in_days">%1$d nap múlva</string>
|
||||
<string name="share_kind_pass">Wallet-kártya</string>
|
||||
<string name="share_kind_pdf">PDF</string>
|
||||
<string name="share_kind_mail">E-mail</string>
|
||||
<string name="share_kind_page">Weboldal</string>
|
||||
<string name="share_kind_text">Szöveg</string>
|
||||
<string name="share_kind_image">Fotó</string>
|
||||
<string name="share_kind_file">Fájl</string>
|
||||
<string name="widget_empty">Nyisd meg egyszer a TREK-et, és itt megjelennek az utazásaid.</string>
|
||||
<string name="push_channel_chat">Utazási csevegés</string>
|
||||
<string name="push_channel_trips">Utazási hírek</string>
|
||||
<string name="push_channel_invites">Meghívások és megosztások</string>
|
||||
<string name="push_channel_name">Általános</string>
|
||||
<string name="push_channel_tripday">Utazási nap</string>
|
||||
<string name="push_fallback_body">Új értesítés</string>
|
||||
<string name="push_channel_tripday_description">Egy folyamatban lévő utazás napja és a következő megálló.</string>
|
||||
</resources>
|
||||
@@ -1,57 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
|
||||
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
|
||||
<resources>
|
||||
<string name="widget_trip_name">Perjalanan</string>
|
||||
<string name="widget_trip_description">Menghitung mundur ke perjalanan berikutnya, lalu menampilkan hari dan apa yang berikutnya.</string>
|
||||
<string name="widget_today_name">Hari ini</string>
|
||||
<string name="widget_today_description">Rencana hari ini sebagai linimasa, dengan cuaca dan perhentian berikutnya.</string>
|
||||
<string name="widget_bookings_name">Pemesanan</string>
|
||||
<string name="widget_bookings_description">Penerbangan, kereta, hotel, dan meja berikutnya, beserta waktunya.</string>
|
||||
<string name="widget_todos_name">Tugas</string>
|
||||
<string name="widget_todos_description">Yang masih tersisa untuk perjalanan dan sejauh mana persiapan barang.</string>
|
||||
<string name="widget_packing_name">Barang bawaan</string>
|
||||
<string name="widget_packing_description">Seberapa penuh tas, beratnya, dan yang masih kurang.</string>
|
||||
<string name="widget_atlas_name">Peta dunia</string>
|
||||
<string name="widget_atlas_description">Negara yang pernah kamu kunjungi, bersinar di peta.</string>
|
||||
<string name="widget_collab_name">Rombongan</string>
|
||||
<string name="widget_collab_description">Siapa yang ikut, pesan terbaru, dan polling yang terbuka.</string>
|
||||
<string name="widget_intent_title">Pilih perjalanan</string>
|
||||
<string name="widget_intent_description">Perjalanan yang ditampilkan widget ini.</string>
|
||||
<string name="widget_intent_trip">Perjalanan</string>
|
||||
<string name="widget_intent_auto">Otomatis</string>
|
||||
<string name="widget_intent_auto_hint">Perjalanan yang sama dengan dasbor</string>
|
||||
<string name="share_title">Tambahkan ke TREK</string>
|
||||
<string name="share_cancel">Batal</string>
|
||||
<string name="share_close">Tutup</string>
|
||||
<string name="share_trip">Perjalanan</string>
|
||||
<string name="share_no_trips">Buka TREK sekali dan perjalanan Anda akan muncul di sini.</string>
|
||||
<string name="share_hint">TREK membaca pemesanan dan menambahkannya ke perjalanan.</string>
|
||||
<string name="share_later">Pilih nanti</string>
|
||||
<string name="share_add">Tambahkan</string>
|
||||
<string name="share_add_to">Tambahkan ke %1$s</string>
|
||||
<string name="share_added">Ditambahkan</string>
|
||||
<string name="share_reading">Membaca…</string>
|
||||
<string name="share_too_large">File ini terlalu besar untuk diimpor.</string>
|
||||
<string name="share_unsupported">TREK tidak dapat mengimpor ini.</string>
|
||||
<string name="share_failed">Tidak dapat diteruskan ke TREK. Silakan coba lagi.</string>
|
||||
<string name="share_now">Sekarang</string>
|
||||
<string name="share_past">Lewat</string>
|
||||
<string name="share_tomorrow">Besok</string>
|
||||
<string name="share_in_days">Dalam %1$d hari</string>
|
||||
<string name="share_kind_pass">Pass Wallet</string>
|
||||
<string name="share_kind_pdf">PDF</string>
|
||||
<string name="share_kind_mail">Email</string>
|
||||
<string name="share_kind_page">Halaman web</string>
|
||||
<string name="share_kind_text">Teks</string>
|
||||
<string name="share_kind_image">Foto</string>
|
||||
<string name="share_kind_file">File</string>
|
||||
<string name="widget_empty">Buka TREK sekali dan perjalanan Anda akan muncul di sini.</string>
|
||||
<string name="push_channel_chat">Obrolan perjalanan</string>
|
||||
<string name="push_channel_trips">Kabar perjalanan</string>
|
||||
<string name="push_channel_invites">Undangan dan berbagi</string>
|
||||
<string name="push_channel_name">Umum</string>
|
||||
<string name="push_channel_tripday">Hari perjalanan</string>
|
||||
<string name="push_fallback_body">Notifikasi baru</string>
|
||||
<string name="push_channel_tripday_description">Hari dari perjalanan yang berlangsung dan perhentian berikutnya.</string>
|
||||
</resources>
|
||||
@@ -1,57 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
|
||||
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
|
||||
<resources>
|
||||
<string name="widget_trip_name">Viaggio</string>
|
||||
<string name="widget_trip_description">Conta i giorni al prossimo viaggio e durante il viaggio mostra la giornata e cosa viene dopo.</string>
|
||||
<string name="widget_today_name">Oggi</string>
|
||||
<string name="widget_today_description">Il programma del giorno come linea del tempo, con il meteo e la prossima tappa.</string>
|
||||
<string name="widget_bookings_name">Prenotazioni</string>
|
||||
<string name="widget_bookings_description">I tuoi prossimi voli, treni, hotel e tavoli, e quando sono.</string>
|
||||
<string name="widget_todos_name">Da fare</string>
|
||||
<string name="widget_todos_description">Cosa manca ancora per il viaggio e a che punto è la valigia.</string>
|
||||
<string name="widget_packing_name">Bagagli</string>
|
||||
<string name="widget_packing_description">Quanto sono piene le borse, il loro peso e cosa manca ancora.</string>
|
||||
<string name="widget_atlas_name">Mappa del mondo</string>
|
||||
<string name="widget_atlas_description">I paesi in cui sei stato, illuminati sulla mappa.</string>
|
||||
<string name="widget_collab_name">Gruppo di viaggio</string>
|
||||
<string name="widget_collab_description">Chi viene, gli ultimi messaggi e il sondaggio aperto.</string>
|
||||
<string name="widget_intent_title">Scegli viaggio</string>
|
||||
<string name="widget_intent_description">Il viaggio mostrato da questo widget.</string>
|
||||
<string name="widget_intent_trip">Viaggio</string>
|
||||
<string name="widget_intent_auto">Automatico</string>
|
||||
<string name="widget_intent_auto_hint">Lo stesso viaggio della dashboard</string>
|
||||
<string name="share_title">Aggiungi a TREK</string>
|
||||
<string name="share_cancel">Annulla</string>
|
||||
<string name="share_close">Chiudi</string>
|
||||
<string name="share_trip">Viaggio</string>
|
||||
<string name="share_no_trips">Apri TREK una volta e qui compariranno i tuoi viaggi.</string>
|
||||
<string name="share_hint">TREK legge la prenotazione e la aggiunge al viaggio.</string>
|
||||
<string name="share_later">Scegli dopo</string>
|
||||
<string name="share_add">Aggiungi</string>
|
||||
<string name="share_add_to">Aggiungi a %1$s</string>
|
||||
<string name="share_added">Aggiunto</string>
|
||||
<string name="share_reading">Lettura…</string>
|
||||
<string name="share_too_large">Questo file è troppo grande da importare.</string>
|
||||
<string name="share_unsupported">TREK non può importare questo contenuto.</string>
|
||||
<string name="share_failed">Impossibile passarlo a TREK. Riprova.</string>
|
||||
<string name="share_now">In corso</string>
|
||||
<string name="share_past">Passato</string>
|
||||
<string name="share_tomorrow">Domani</string>
|
||||
<string name="share_in_days">Tra %1$d giorni</string>
|
||||
<string name="share_kind_pass">Pass di Wallet</string>
|
||||
<string name="share_kind_pdf">PDF</string>
|
||||
<string name="share_kind_mail">E-mail</string>
|
||||
<string name="share_kind_page">Pagina web</string>
|
||||
<string name="share_kind_text">Testo</string>
|
||||
<string name="share_kind_image">Foto</string>
|
||||
<string name="share_kind_file">File</string>
|
||||
<string name="widget_empty">Apri TREK una volta e qui compariranno i tuoi viaggi.</string>
|
||||
<string name="push_channel_chat">Chat del viaggio</string>
|
||||
<string name="push_channel_trips">Novità del viaggio</string>
|
||||
<string name="push_channel_invites">Inviti e condivisioni</string>
|
||||
<string name="push_channel_name">Generali</string>
|
||||
<string name="push_channel_tripday">Giorno di viaggio</string>
|
||||
<string name="push_fallback_body">Nuova notifica</string>
|
||||
<string name="push_channel_tripday_description">La giornata di un viaggio in corso e la prossima tappa.</string>
|
||||
</resources>
|
||||
@@ -1,57 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
|
||||
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
|
||||
<resources>
|
||||
<string name="widget_trip_name">טיול</string>
|
||||
<string name="widget_trip_description">סופר את הימים עד הטיול הבא, ובמהלכו מציג את היום ואת מה שבא אחריו.</string>
|
||||
<string name="widget_today_name">היום</string>
|
||||
<string name="widget_today_description">תוכנית היום כציר זמן, עם מזג האוויר והעצירה הבאה.</string>
|
||||
<string name="widget_bookings_name">הזמנות</string>
|
||||
<string name="widget_bookings_description">הטיסות, הרכבות, המלונות והשולחנות הבאים שלך, ומתי הם.</string>
|
||||
<string name="widget_todos_name">משימות</string>
|
||||
<string name="widget_todos_description">מה עוד פתוח לטיול וכמה התקדמה האריזה.</string>
|
||||
<string name="widget_packing_name">אריזה</string>
|
||||
<string name="widget_packing_description">כמה מלאים התיקים, המשקל שלהם ומה עוד חסר.</string>
|
||||
<string name="widget_atlas_name">מפת העולם</string>
|
||||
<string name="widget_atlas_description">המדינות שביקרת בהן, זוהרות על המפה.</string>
|
||||
<string name="widget_collab_name">קבוצת הטיול</string>
|
||||
<string name="widget_collab_description">מי מצטרף, ההודעות האחרונות והסקר הפתוח.</string>
|
||||
<string name="widget_intent_title">בחירת טיול</string>
|
||||
<string name="widget_intent_description">הטיול שהווידג׳ט הזה מציג.</string>
|
||||
<string name="widget_intent_trip">טיול</string>
|
||||
<string name="widget_intent_auto">אוטומטי</string>
|
||||
<string name="widget_intent_auto_hint">אותו טיול כמו בלוח הבקרה</string>
|
||||
<string name="share_title">הוספה ל-TREK</string>
|
||||
<string name="share_cancel">ביטול</string>
|
||||
<string name="share_close">סגירה</string>
|
||||
<string name="share_trip">טיול</string>
|
||||
<string name="share_no_trips">פתחו את TREK פעם אחת והטיולים שלכם יופיעו כאן.</string>
|
||||
<string name="share_hint">TREK קורא את ההזמנה ומוסיף אותה לטיול.</string>
|
||||
<string name="share_later">לבחור אחר כך</string>
|
||||
<string name="share_add">הוספה</string>
|
||||
<string name="share_add_to">הוספה אל %1$s</string>
|
||||
<string name="share_added">נוסף</string>
|
||||
<string name="share_reading">קורא…</string>
|
||||
<string name="share_too_large">הקובץ גדול מדי לייבוא.</string>
|
||||
<string name="share_unsupported">TREK לא יכול לייבא את זה.</string>
|
||||
<string name="share_failed">לא ניתן היה להעביר את זה ל-TREK. אנא נסו שוב.</string>
|
||||
<string name="share_now">עכשיו</string>
|
||||
<string name="share_past">עבר</string>
|
||||
<string name="share_tomorrow">מחר</string>
|
||||
<string name="share_in_days">בעוד %1$d ימים</string>
|
||||
<string name="share_kind_pass">כרטיס Wallet</string>
|
||||
<string name="share_kind_pdf">PDF</string>
|
||||
<string name="share_kind_mail">דוא״ל</string>
|
||||
<string name="share_kind_page">דף אינטרנט</string>
|
||||
<string name="share_kind_text">טקסט</string>
|
||||
<string name="share_kind_image">תמונה</string>
|
||||
<string name="share_kind_file">קובץ</string>
|
||||
<string name="widget_empty">פתחו את TREK פעם אחת והטיולים שלכם יופיעו כאן.</string>
|
||||
<string name="push_channel_chat">צ׳אט הטיול</string>
|
||||
<string name="push_channel_trips">עדכוני הטיול</string>
|
||||
<string name="push_channel_invites">הזמנות ושיתופים</string>
|
||||
<string name="push_channel_name">כללי</string>
|
||||
<string name="push_channel_tripday">יום טיול</string>
|
||||
<string name="push_fallback_body">התראה חדשה</string>
|
||||
<string name="push_channel_tripday_description">היום של טיול פעיל והעצירה הבאה שלו.</string>
|
||||
</resources>
|
||||
@@ -1,57 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
|
||||
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
|
||||
<resources>
|
||||
<string name="widget_trip_name">旅行</string>
|
||||
<string name="widget_trip_description">次の旅行までをカウントダウンし、旅行中はその日の予定と次の行き先を表示します。</string>
|
||||
<string name="widget_today_name">今日</string>
|
||||
<string name="widget_today_description">今日の予定をタイムラインで、天気と次の予定とともに表示します。</string>
|
||||
<string name="widget_bookings_name">予約</string>
|
||||
<string name="widget_bookings_description">次のフライト、列車、ホテル、レストランとその日時。</string>
|
||||
<string name="widget_todos_name">やること</string>
|
||||
<string name="widget_todos_description">旅行のために残っていることと、荷造りの進み具合。</string>
|
||||
<string name="widget_packing_name">持ち物</string>
|
||||
<string name="widget_packing_description">バッグの詰め具合と重さ、まだ足りないもの。</string>
|
||||
<string name="widget_atlas_name">世界地図</string>
|
||||
<string name="widget_atlas_description">訪れた国が地図上で光ります。</string>
|
||||
<string name="widget_collab_name">旅行グループ</string>
|
||||
<string name="widget_collab_description">一緒に行くメンバー、最新のメッセージ、受付中の投票。</string>
|
||||
<string name="widget_intent_title">旅行を選択</string>
|
||||
<string name="widget_intent_description">このウィジェットに表示する旅行。</string>
|
||||
<string name="widget_intent_trip">旅行</string>
|
||||
<string name="widget_intent_auto">自動</string>
|
||||
<string name="widget_intent_auto_hint">ダッシュボードと同じ旅行</string>
|
||||
<string name="share_title">TREKに追加</string>
|
||||
<string name="share_cancel">キャンセル</string>
|
||||
<string name="share_close">閉じる</string>
|
||||
<string name="share_trip">旅行</string>
|
||||
<string name="share_no_trips">一度TREKを開くと、ここに旅行が表示されます。</string>
|
||||
<string name="share_hint">TREKが予約を読み取り、旅行に追加します。</string>
|
||||
<string name="share_later">あとで選ぶ</string>
|
||||
<string name="share_add">追加</string>
|
||||
<string name="share_add_to">%1$sに追加</string>
|
||||
<string name="share_added">追加しました</string>
|
||||
<string name="share_reading">読み込み中…</string>
|
||||
<string name="share_too_large">このファイルは大きすぎて読み込めません。</string>
|
||||
<string name="share_unsupported">TREKではこれを読み込めません。</string>
|
||||
<string name="share_failed">TREKに渡せませんでした。もう一度お試しください。</string>
|
||||
<string name="share_now">旅行中</string>
|
||||
<string name="share_past">終了</string>
|
||||
<string name="share_tomorrow">明日</string>
|
||||
<string name="share_in_days">%1$d日後</string>
|
||||
<string name="share_kind_pass">Walletパス</string>
|
||||
<string name="share_kind_pdf">PDF</string>
|
||||
<string name="share_kind_mail">メール</string>
|
||||
<string name="share_kind_page">Webページ</string>
|
||||
<string name="share_kind_text">テキスト</string>
|
||||
<string name="share_kind_image">写真</string>
|
||||
<string name="share_kind_file">ファイル</string>
|
||||
<string name="widget_empty">一度TREKを開くと、ここに旅行が表示されます。</string>
|
||||
<string name="push_channel_chat">旅行チャット</string>
|
||||
<string name="push_channel_trips">旅行の更新</string>
|
||||
<string name="push_channel_invites">招待と共有</string>
|
||||
<string name="push_channel_name">一般</string>
|
||||
<string name="push_channel_tripday">旅行日</string>
|
||||
<string name="push_fallback_body">新しい通知</string>
|
||||
<string name="push_channel_tripday_description">進行中の旅行の一日と次の立ち寄り先。</string>
|
||||
</resources>
|
||||
@@ -1,57 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
|
||||
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
|
||||
<resources>
|
||||
<string name="widget_trip_name">여행</string>
|
||||
<string name="widget_trip_description">다음 여행까지 남은 날을 세고, 여행 중에는 오늘과 다음 일정을 보여 줍니다.</string>
|
||||
<string name="widget_today_name">오늘</string>
|
||||
<string name="widget_today_description">오늘 일정을 타임라인으로, 날씨와 다음 장소와 함께 보여 줍니다.</string>
|
||||
<string name="widget_bookings_name">예약</string>
|
||||
<string name="widget_bookings_description">다가오는 항공편, 기차, 호텔, 식당과 그 시간.</string>
|
||||
<string name="widget_todos_name">할 일</string>
|
||||
<string name="widget_todos_description">여행을 위해 남은 일과 짐 싸기 진행 상황.</string>
|
||||
<string name="widget_packing_name">짐 싸기</string>
|
||||
<string name="widget_packing_description">가방이 얼마나 찼는지, 무게, 아직 빠진 것.</string>
|
||||
<string name="widget_atlas_name">세계 지도</string>
|
||||
<string name="widget_atlas_description">다녀온 나라가 지도 위에서 빛납니다.</string>
|
||||
<string name="widget_collab_name">여행 그룹</string>
|
||||
<string name="widget_collab_description">함께 가는 사람, 최근 메시지, 진행 중인 투표.</string>
|
||||
<string name="widget_intent_title">여행 선택</string>
|
||||
<string name="widget_intent_description">이 위젯에 표시할 여행.</string>
|
||||
<string name="widget_intent_trip">여행</string>
|
||||
<string name="widget_intent_auto">자동</string>
|
||||
<string name="widget_intent_auto_hint">대시보드와 같은 여행</string>
|
||||
<string name="share_title">TREK에 추가</string>
|
||||
<string name="share_cancel">취소</string>
|
||||
<string name="share_close">닫기</string>
|
||||
<string name="share_trip">여행</string>
|
||||
<string name="share_no_trips">TREK을 한 번 열면 여기에 여행이 표시됩니다.</string>
|
||||
<string name="share_hint">TREK이 예약을 읽고 여행에 추가합니다.</string>
|
||||
<string name="share_later">나중에 선택</string>
|
||||
<string name="share_add">추가</string>
|
||||
<string name="share_add_to">%1$s에 추가</string>
|
||||
<string name="share_added">추가됨</string>
|
||||
<string name="share_reading">읽는 중…</string>
|
||||
<string name="share_too_large">이 파일은 가져오기에는 너무 큽니다.</string>
|
||||
<string name="share_unsupported">TREK에서 가져올 수 없는 항목입니다.</string>
|
||||
<string name="share_failed">TREK에 전달하지 못했습니다. 다시 시도하세요.</string>
|
||||
<string name="share_now">진행 중</string>
|
||||
<string name="share_past">지난 여행</string>
|
||||
<string name="share_tomorrow">내일</string>
|
||||
<string name="share_in_days">%1$d일 후</string>
|
||||
<string name="share_kind_pass">Wallet 패스</string>
|
||||
<string name="share_kind_pdf">PDF</string>
|
||||
<string name="share_kind_mail">이메일</string>
|
||||
<string name="share_kind_page">웹 페이지</string>
|
||||
<string name="share_kind_text">텍스트</string>
|
||||
<string name="share_kind_image">사진</string>
|
||||
<string name="share_kind_file">파일</string>
|
||||
<string name="widget_empty">TREK을 한 번 열면 여기에 여행이 표시됩니다.</string>
|
||||
<string name="push_channel_chat">여행 채팅</string>
|
||||
<string name="push_channel_trips">여행 소식</string>
|
||||
<string name="push_channel_invites">초대 및 공유</string>
|
||||
<string name="push_channel_name">일반</string>
|
||||
<string name="push_channel_tripday">여행일</string>
|
||||
<string name="push_fallback_body">새 알림</string>
|
||||
<string name="push_channel_tripday_description">진행 중인 여행의 하루와 다음 장소.</string>
|
||||
</resources>
|
||||
@@ -1,57 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
|
||||
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
|
||||
<resources>
|
||||
<string name="widget_trip_name">Reis</string>
|
||||
<string name="widget_trip_description">Telt af naar je volgende reis en toont onderweg de dag en wat er hierna komt.</string>
|
||||
<string name="widget_today_name">Vandaag</string>
|
||||
<string name="widget_today_description">Het dagplan als tijdlijn, met het weer en de volgende stop.</string>
|
||||
<string name="widget_bookings_name">Boekingen</string>
|
||||
<string name="widget_bookings_description">Je volgende vluchten, treinen, hotels en tafels, en wanneer ze zijn.</string>
|
||||
<string name="widget_todos_name">Taken</string>
|
||||
<string name="widget_todos_description">Wat er nog openstaat voor de reis en hoe ver het inpakken is.</string>
|
||||
<string name="widget_packing_name">Bagage</string>
|
||||
<string name="widget_packing_description">Hoe vol de tassen zijn, hun gewicht en wat er nog mist.</string>
|
||||
<string name="widget_atlas_name">Wereldkaart</string>
|
||||
<string name="widget_atlas_description">De landen waar je bent geweest, oplichtend op de kaart.</string>
|
||||
<string name="widget_collab_name">Reisgezelschap</string>
|
||||
<string name="widget_collab_description">Wie er meegaat, de nieuwste berichten en de open peiling.</string>
|
||||
<string name="widget_intent_title">Reis kiezen</string>
|
||||
<string name="widget_intent_description">De reis die deze widget toont.</string>
|
||||
<string name="widget_intent_trip">Reis</string>
|
||||
<string name="widget_intent_auto">Automatisch</string>
|
||||
<string name="widget_intent_auto_hint">Dezelfde reis als het dashboard</string>
|
||||
<string name="share_title">Toevoegen aan TREK</string>
|
||||
<string name="share_cancel">Annuleer</string>
|
||||
<string name="share_close">Sluit</string>
|
||||
<string name="share_trip">Reis</string>
|
||||
<string name="share_no_trips">Open TREK één keer, dan verschijnen je reizen hier.</string>
|
||||
<string name="share_hint">TREK leest de boeking en zet hem in de reis.</string>
|
||||
<string name="share_later">Later kiezen</string>
|
||||
<string name="share_add">Toevoegen</string>
|
||||
<string name="share_add_to">Toevoegen aan %1$s</string>
|
||||
<string name="share_added">Toegevoegd</string>
|
||||
<string name="share_reading">Lezen…</string>
|
||||
<string name="share_too_large">Dit bestand is te groot om te importeren.</string>
|
||||
<string name="share_unsupported">Dit kan TREK niet importeren.</string>
|
||||
<string name="share_failed">Dit kon niet aan TREK worden doorgegeven. Probeer het opnieuw.</string>
|
||||
<string name="share_now">Nu</string>
|
||||
<string name="share_past">Voorbij</string>
|
||||
<string name="share_tomorrow">Morgen</string>
|
||||
<string name="share_in_days">Over %1$d dagen</string>
|
||||
<string name="share_kind_pass">Wallet-pas</string>
|
||||
<string name="share_kind_pdf">PDF</string>
|
||||
<string name="share_kind_mail">E-mail</string>
|
||||
<string name="share_kind_page">Webpagina</string>
|
||||
<string name="share_kind_text">Tekst</string>
|
||||
<string name="share_kind_image">Foto</string>
|
||||
<string name="share_kind_file">Bestand</string>
|
||||
<string name="widget_empty">Open TREK één keer, dan verschijnen je reizen hier.</string>
|
||||
<string name="push_channel_chat">Reischat</string>
|
||||
<string name="push_channel_trips">Reisupdates</string>
|
||||
<string name="push_channel_invites">Uitnodigingen en delen</string>
|
||||
<string name="push_channel_name">Algemeen</string>
|
||||
<string name="push_channel_tripday">Reisdag</string>
|
||||
<string name="push_fallback_body">Nieuwe melding</string>
|
||||
<string name="push_channel_tripday_description">De dag van een lopende reis en de volgende stop.</string>
|
||||
</resources>
|
||||
@@ -1,57 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
|
||||
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
|
||||
<resources>
|
||||
<string name="widget_trip_name">Podróż</string>
|
||||
<string name="widget_trip_description">Odlicza dni do następnej podróży, a w trakcie pokazuje dzień i to, co dalej.</string>
|
||||
<string name="widget_today_name">Dziś</string>
|
||||
<string name="widget_today_description">Plan dnia jako oś czasu, z pogodą i kolejnym przystankiem.</string>
|
||||
<string name="widget_bookings_name">Rezerwacje</string>
|
||||
<string name="widget_bookings_description">Twoje najbliższe loty, pociągi, hotele i stoliki oraz ich terminy.</string>
|
||||
<string name="widget_todos_name">Zadania</string>
|
||||
<string name="widget_todos_description">Co jeszcze zostało do zrobienia przed podróżą i jak idzie pakowanie.</string>
|
||||
<string name="widget_packing_name">Pakowanie</string>
|
||||
<string name="widget_packing_description">Jak pełne są torby, ich waga i czego jeszcze brakuje.</string>
|
||||
<string name="widget_atlas_name">Mapa świata</string>
|
||||
<string name="widget_atlas_description">Kraje, w których byłeś, świecące na mapie.</string>
|
||||
<string name="widget_collab_name">Grupa podróży</string>
|
||||
<string name="widget_collab_description">Kto jedzie, najnowsze wiadomości i otwarta ankieta.</string>
|
||||
<string name="widget_intent_title">Wybierz podróż</string>
|
||||
<string name="widget_intent_description">Podróż, którą pokazuje ten widżet.</string>
|
||||
<string name="widget_intent_trip">Podróż</string>
|
||||
<string name="widget_intent_auto">Automatycznie</string>
|
||||
<string name="widget_intent_auto_hint">Ta sama podróż co na pulpicie</string>
|
||||
<string name="share_title">Dodaj do TREK</string>
|
||||
<string name="share_cancel">Anuluj</string>
|
||||
<string name="share_close">Zamknij</string>
|
||||
<string name="share_trip">Podróż</string>
|
||||
<string name="share_no_trips">Otwórz TREK raz, a Twoje podróże pojawią się tutaj.</string>
|
||||
<string name="share_hint">TREK odczyta rezerwację i doda ją do podróży.</string>
|
||||
<string name="share_later">Wybierz później</string>
|
||||
<string name="share_add">Dodaj</string>
|
||||
<string name="share_add_to">Dodaj do %1$s</string>
|
||||
<string name="share_added">Dodano</string>
|
||||
<string name="share_reading">Odczytywanie…</string>
|
||||
<string name="share_too_large">Ten plik jest za duży do zaimportowania.</string>
|
||||
<string name="share_unsupported">TREK nie może tego zaimportować.</string>
|
||||
<string name="share_failed">Nie udało się przekazać tego do TREK. Spróbuj ponownie.</string>
|
||||
<string name="share_now">Teraz</string>
|
||||
<string name="share_past">Minęła</string>
|
||||
<string name="share_tomorrow">Jutro</string>
|
||||
<string name="share_in_days">Za %1$d dni</string>
|
||||
<string name="share_kind_pass">Karta Wallet</string>
|
||||
<string name="share_kind_pdf">PDF</string>
|
||||
<string name="share_kind_mail">E-mail</string>
|
||||
<string name="share_kind_page">Strona</string>
|
||||
<string name="share_kind_text">Tekst</string>
|
||||
<string name="share_kind_image">Zdjęcie</string>
|
||||
<string name="share_kind_file">Plik</string>
|
||||
<string name="widget_empty">Otwórz TREK raz, a Twoje podróże pojawią się tutaj.</string>
|
||||
<string name="push_channel_chat">Czat podróży</string>
|
||||
<string name="push_channel_trips">Nowości o podróży</string>
|
||||
<string name="push_channel_invites">Zaproszenia i udostępnienia</string>
|
||||
<string name="push_channel_name">Ogólne</string>
|
||||
<string name="push_channel_tripday">Dzień podróży</string>
|
||||
<string name="push_fallback_body">Nowe powiadomienie</string>
|
||||
<string name="push_channel_tripday_description">Dzień trwającej podróży i jej następny przystanek.</string>
|
||||
</resources>
|
||||
@@ -1,57 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
|
||||
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
|
||||
<resources>
|
||||
<string name="widget_trip_name">Viagem</string>
|
||||
<string name="widget_trip_description">Conta os dias até a próxima viagem e, durante ela, mostra o dia e o que vem a seguir.</string>
|
||||
<string name="widget_today_name">Hoje</string>
|
||||
<string name="widget_today_description">O plano do dia como linha do tempo, com o clima e a próxima parada.</string>
|
||||
<string name="widget_bookings_name">Reservas</string>
|
||||
<string name="widget_bookings_description">Seus próximos voos, trens, hotéis e mesas, e quando acontecem.</string>
|
||||
<string name="widget_todos_name">Tarefas</string>
|
||||
<string name="widget_todos_description">O que ainda falta para a viagem e quanto da mala já está pronto.</string>
|
||||
<string name="widget_packing_name">Bagagem</string>
|
||||
<string name="widget_packing_description">Quão cheias estão as malas, o peso e o que ainda falta.</string>
|
||||
<string name="widget_atlas_name">Mapa-múndi</string>
|
||||
<string name="widget_atlas_description">Os países em que você esteve, brilhando no mapa.</string>
|
||||
<string name="widget_collab_name">Grupo de viagem</string>
|
||||
<string name="widget_collab_description">Quem vai junto, as mensagens recentes e a votação aberta.</string>
|
||||
<string name="widget_intent_title">Escolher viagem</string>
|
||||
<string name="widget_intent_description">A viagem que este widget mostra.</string>
|
||||
<string name="widget_intent_trip">Viagem</string>
|
||||
<string name="widget_intent_auto">Automático</string>
|
||||
<string name="widget_intent_auto_hint">A mesma viagem do painel</string>
|
||||
<string name="share_title">Adicionar ao TREK</string>
|
||||
<string name="share_cancel">Cancelar</string>
|
||||
<string name="share_close">Fechar</string>
|
||||
<string name="share_trip">Viagem</string>
|
||||
<string name="share_no_trips">Abra o TREK uma vez e suas viagens aparecerão aqui.</string>
|
||||
<string name="share_hint">O TREK lê a reserva e a adiciona à viagem.</string>
|
||||
<string name="share_later">Escolher depois</string>
|
||||
<string name="share_add">Adicionar</string>
|
||||
<string name="share_add_to">Adicionar a %1$s</string>
|
||||
<string name="share_added">Adicionado</string>
|
||||
<string name="share_reading">Lendo…</string>
|
||||
<string name="share_too_large">Este arquivo é grande demais para importar.</string>
|
||||
<string name="share_unsupported">O TREK não consegue importar isto.</string>
|
||||
<string name="share_failed">Não foi possível passar isto ao TREK. Tente novamente.</string>
|
||||
<string name="share_now">Agora</string>
|
||||
<string name="share_past">Passada</string>
|
||||
<string name="share_tomorrow">Amanhã</string>
|
||||
<string name="share_in_days">Em %1$d dias</string>
|
||||
<string name="share_kind_pass">Passe do Wallet</string>
|
||||
<string name="share_kind_pdf">PDF</string>
|
||||
<string name="share_kind_mail">E-mail</string>
|
||||
<string name="share_kind_page">Página web</string>
|
||||
<string name="share_kind_text">Texto</string>
|
||||
<string name="share_kind_image">Foto</string>
|
||||
<string name="share_kind_file">Arquivo</string>
|
||||
<string name="widget_empty">Abra o TREK uma vez e suas viagens aparecerão aqui.</string>
|
||||
<string name="push_channel_chat">Chat da viagem</string>
|
||||
<string name="push_channel_trips">Novidades da viagem</string>
|
||||
<string name="push_channel_invites">Convites e compartilhamentos</string>
|
||||
<string name="push_channel_name">Geral</string>
|
||||
<string name="push_channel_tripday">Dia de viagem</string>
|
||||
<string name="push_fallback_body">Nova notificação</string>
|
||||
<string name="push_channel_tripday_description">O dia de uma viagem em andamento e sua próxima parada.</string>
|
||||
</resources>
|
||||
@@ -1,57 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
|
||||
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
|
||||
<resources>
|
||||
<string name="widget_trip_name">Поездка</string>
|
||||
<string name="widget_trip_description">Отсчитывает дни до следующей поездки, а в пути показывает день и что дальше.</string>
|
||||
<string name="widget_today_name">Сегодня</string>
|
||||
<string name="widget_today_description">План дня в виде ленты времени, с погодой и следующей остановкой.</string>
|
||||
<string name="widget_bookings_name">Бронирования</string>
|
||||
<string name="widget_bookings_description">Ближайшие рейсы, поезда, отели и столики и когда они.</string>
|
||||
<string name="widget_todos_name">Задачи</string>
|
||||
<string name="widget_todos_description">Что ещё осталось сделать к поездке и как идут сборы.</string>
|
||||
<string name="widget_packing_name">Сборы</string>
|
||||
<string name="widget_packing_description">Насколько заполнены сумки, их вес и чего ещё не хватает.</string>
|
||||
<string name="widget_atlas_name">Карта мира</string>
|
||||
<string name="widget_atlas_description">Страны, где вы были, светятся на карте.</string>
|
||||
<string name="widget_collab_name">Группа поездки</string>
|
||||
<string name="widget_collab_description">Кто едет, последние сообщения и открытый опрос.</string>
|
||||
<string name="widget_intent_title">Выбрать поездку</string>
|
||||
<string name="widget_intent_description">Поездка, которую показывает этот виджет.</string>
|
||||
<string name="widget_intent_trip">Поездка</string>
|
||||
<string name="widget_intent_auto">Автоматически</string>
|
||||
<string name="widget_intent_auto_hint">Та же поездка, что и на главной</string>
|
||||
<string name="share_title">Добавить в TREK</string>
|
||||
<string name="share_cancel">Отменить</string>
|
||||
<string name="share_close">Закрыть</string>
|
||||
<string name="share_trip">Поездка</string>
|
||||
<string name="share_no_trips">Откройте TREK один раз, и ваши поездки появятся здесь.</string>
|
||||
<string name="share_hint">TREK прочитает бронь и добавит её в поездку.</string>
|
||||
<string name="share_later">Выбрать позже</string>
|
||||
<string name="share_add">Добавить</string>
|
||||
<string name="share_add_to">Добавить в «%1$s»</string>
|
||||
<string name="share_added">Добавлено</string>
|
||||
<string name="share_reading">Чтение…</string>
|
||||
<string name="share_too_large">Файл слишком большой для импорта.</string>
|
||||
<string name="share_unsupported">TREK не может это импортировать.</string>
|
||||
<string name="share_failed">Не удалось передать это в TREK. Повторите попытку.</string>
|
||||
<string name="share_now">Сейчас</string>
|
||||
<string name="share_past">Прошла</string>
|
||||
<string name="share_tomorrow">Завтра</string>
|
||||
<string name="share_in_days">Через %1$d дн.</string>
|
||||
<string name="share_kind_pass">Карта Wallet</string>
|
||||
<string name="share_kind_pdf">PDF</string>
|
||||
<string name="share_kind_mail">Письмо</string>
|
||||
<string name="share_kind_page">Веб-страница</string>
|
||||
<string name="share_kind_text">Текст</string>
|
||||
<string name="share_kind_image">Фото</string>
|
||||
<string name="share_kind_file">Файл</string>
|
||||
<string name="widget_empty">Откройте TREK один раз, и ваши поездки появятся здесь.</string>
|
||||
<string name="push_channel_chat">Чат поездки</string>
|
||||
<string name="push_channel_trips">Новости поездки</string>
|
||||
<string name="push_channel_invites">Приглашения и доступ</string>
|
||||
<string name="push_channel_name">Общие</string>
|
||||
<string name="push_channel_tripday">День поездки</string>
|
||||
<string name="push_fallback_body">Новое уведомление</string>
|
||||
<string name="push_channel_tripday_description">День текущей поездки и следующая остановка.</string>
|
||||
</resources>
|
||||
@@ -1,57 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
|
||||
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
|
||||
<resources>
|
||||
<string name="widget_trip_name">Cesta</string>
|
||||
<string name="widget_trip_description">Odpočítava dni do ďalšej cesty a počas nej ukazuje deň a čo príde ďalej.</string>
|
||||
<string name="widget_today_name">Dnes</string>
|
||||
<string name="widget_today_description">Plán dňa ako časová os, s počasím a ďalšou zastávkou.</string>
|
||||
<string name="widget_bookings_name">Rezervácie</string>
|
||||
<string name="widget_bookings_description">Tvoje ďalšie lety, vlaky, hotely a stoly a kedy ťa čakajú.</string>
|
||||
<string name="widget_todos_name">Úlohy</string>
|
||||
<string name="widget_todos_description">Čo ešte zostáva na cestu a ako ďaleko je balenie.</string>
|
||||
<string name="widget_packing_name">Balenie</string>
|
||||
<string name="widget_packing_description">Aké plné sú tašky, ich váha a čo ešte chýba.</string>
|
||||
<string name="widget_atlas_name">Mapa sveta</string>
|
||||
<string name="widget_atlas_description">Krajiny, kde si bol, žiariace na mape.</string>
|
||||
<string name="widget_collab_name">Cestovná skupina</string>
|
||||
<string name="widget_collab_description">Kto ide s tebou, najnovšie správy a otvorené hlasovanie.</string>
|
||||
<string name="widget_intent_title">Vybrať cestu</string>
|
||||
<string name="widget_intent_description">Cesta, ktorú zobrazuje tento widget.</string>
|
||||
<string name="widget_intent_trip">Cesta</string>
|
||||
<string name="widget_intent_auto">Automaticky</string>
|
||||
<string name="widget_intent_auto_hint">Rovnaká cesta ako na nástenke</string>
|
||||
<string name="share_title">Pridať do TREK</string>
|
||||
<string name="share_cancel">Zrušiť</string>
|
||||
<string name="share_close">Zavrieť</string>
|
||||
<string name="share_trip">Cesta</string>
|
||||
<string name="share_no_trips">Raz otvorte TREK a vaše cesty sa zobrazia tu.</string>
|
||||
<string name="share_hint">TREK prečíta rezerváciu a pridá ju k ceste.</string>
|
||||
<string name="share_later">Vybrať neskôr</string>
|
||||
<string name="share_add">Pridať</string>
|
||||
<string name="share_add_to">Pridať do %1$s</string>
|
||||
<string name="share_added">Pridané</string>
|
||||
<string name="share_reading">Načítava sa…</string>
|
||||
<string name="share_too_large">Tento súbor je na import príliš veľký.</string>
|
||||
<string name="share_unsupported">Toto TREK nevie importovať.</string>
|
||||
<string name="share_failed">Nepodarilo sa to odovzdať do TREK. Skúste to znova.</string>
|
||||
<string name="share_now">Teraz</string>
|
||||
<string name="share_past">Prebehla</string>
|
||||
<string name="share_tomorrow">Zajtra</string>
|
||||
<string name="share_in_days">O %1$d dní</string>
|
||||
<string name="share_kind_pass">Karta Wallet</string>
|
||||
<string name="share_kind_pdf">PDF</string>
|
||||
<string name="share_kind_mail">E-mail</string>
|
||||
<string name="share_kind_page">Webová stránka</string>
|
||||
<string name="share_kind_text">Text</string>
|
||||
<string name="share_kind_image">Fotka</string>
|
||||
<string name="share_kind_file">Súbor</string>
|
||||
<string name="widget_empty">Raz otvorte TREK a vaše cesty sa zobrazia tu.</string>
|
||||
<string name="push_channel_chat">Chat výletu</string>
|
||||
<string name="push_channel_trips">Novinky z výletu</string>
|
||||
<string name="push_channel_invites">Pozvánky a zdieľania</string>
|
||||
<string name="push_channel_name">Všeobecné</string>
|
||||
<string name="push_channel_tripday">Cestovný deň</string>
|
||||
<string name="push_fallback_body">Nové upozornenie</string>
|
||||
<string name="push_channel_tripday_description">Deň prebiehajúceho výletu a jeho ďalšia zastávka.</string>
|
||||
</resources>
|
||||
@@ -1,57 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
|
||||
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
|
||||
<resources>
|
||||
<string name="widget_trip_name">Resa</string>
|
||||
<string name="widget_trip_description">Räknar ner till nästa resa och visar under resan dagen och vad som kommer härnäst.</string>
|
||||
<string name="widget_today_name">Idag</string>
|
||||
<string name="widget_today_description">Dagens plan som tidslinje, med vädret och nästa stopp.</string>
|
||||
<string name="widget_bookings_name">Bokningar</string>
|
||||
<string name="widget_bookings_description">Dina nästa flyg, tåg, hotell och bord, och när de är.</string>
|
||||
<string name="widget_todos_name">Att göra</string>
|
||||
<string name="widget_todos_description">Vad som återstår inför resan och hur långt packningen har kommit.</string>
|
||||
<string name="widget_packing_name">Packning</string>
|
||||
<string name="widget_packing_description">Hur fulla väskorna är, deras vikt och vad som fortfarande saknas.</string>
|
||||
<string name="widget_atlas_name">Världskarta</string>
|
||||
<string name="widget_atlas_description">Länderna du har besökt, lysande på kartan.</string>
|
||||
<string name="widget_collab_name">Resesällskap</string>
|
||||
<string name="widget_collab_description">Vem som följer med, de senaste meddelandena och den öppna omröstningen.</string>
|
||||
<string name="widget_intent_title">Välj resa</string>
|
||||
<string name="widget_intent_description">Resan som den här widgeten visar.</string>
|
||||
<string name="widget_intent_trip">Resa</string>
|
||||
<string name="widget_intent_auto">Automatiskt</string>
|
||||
<string name="widget_intent_auto_hint">Samma resa som på startsidan</string>
|
||||
<string name="share_title">Lägg till i TREK</string>
|
||||
<string name="share_cancel">Avbryt</string>
|
||||
<string name="share_close">Stäng</string>
|
||||
<string name="share_trip">Resa</string>
|
||||
<string name="share_no_trips">Öppna TREK en gång så visas dina resor här.</string>
|
||||
<string name="share_hint">TREK läser bokningen och lägger till den i resan.</string>
|
||||
<string name="share_later">Välj senare</string>
|
||||
<string name="share_add">Lägg till</string>
|
||||
<string name="share_add_to">Lägg till i %1$s</string>
|
||||
<string name="share_added">Tillagd</string>
|
||||
<string name="share_reading">Läser…</string>
|
||||
<string name="share_too_large">Filen är för stor för att importeras.</string>
|
||||
<string name="share_unsupported">Det här kan TREK inte importera.</string>
|
||||
<string name="share_failed">Det gick inte att lämna över till TREK. Försök igen.</string>
|
||||
<string name="share_now">Nu</string>
|
||||
<string name="share_past">Förbi</string>
|
||||
<string name="share_tomorrow">I morgon</string>
|
||||
<string name="share_in_days">Om %1$d dagar</string>
|
||||
<string name="share_kind_pass">Wallet-kort</string>
|
||||
<string name="share_kind_pdf">PDF</string>
|
||||
<string name="share_kind_mail">Mejl</string>
|
||||
<string name="share_kind_page">Webbsida</string>
|
||||
<string name="share_kind_text">Text</string>
|
||||
<string name="share_kind_image">Foto</string>
|
||||
<string name="share_kind_file">Fil</string>
|
||||
<string name="widget_empty">Öppna TREK en gång så visas dina resor här.</string>
|
||||
<string name="push_channel_chat">Resechatt</string>
|
||||
<string name="push_channel_trips">Nytt om resan</string>
|
||||
<string name="push_channel_invites">Inbjudningar och delningar</string>
|
||||
<string name="push_channel_name">Allmänt</string>
|
||||
<string name="push_channel_tripday">Resdag</string>
|
||||
<string name="push_fallback_body">Ny avisering</string>
|
||||
<string name="push_channel_tripday_description">Dagen för en pågående resa och nästa stopp.</string>
|
||||
</resources>
|
||||
@@ -1,57 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
|
||||
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
|
||||
<resources>
|
||||
<string name="widget_trip_name">ทริป</string>
|
||||
<string name="widget_trip_description">นับถอยหลังสู่ทริปถัดไป และระหว่างทริปแสดงวันนี้และสิ่งที่จะตามมา</string>
|
||||
<string name="widget_today_name">วันนี้</string>
|
||||
<string name="widget_today_description">แผนของวันนี้เป็นไทม์ไลน์ พร้อมสภาพอากาศและจุดหมายถัดไป</string>
|
||||
<string name="widget_bookings_name">การจอง</string>
|
||||
<string name="widget_bookings_description">เที่ยวบิน รถไฟ โรงแรม และร้านอาหารถัดไปของคุณ และเวลาของแต่ละรายการ</string>
|
||||
<string name="widget_todos_name">สิ่งที่ต้องทำ</string>
|
||||
<string name="widget_todos_description">สิ่งที่ยังค้างอยู่สำหรับทริป และความคืบหน้าการจัดกระเป๋า</string>
|
||||
<string name="widget_packing_name">จัดกระเป๋า</string>
|
||||
<string name="widget_packing_description">กระเป๋าเต็มแค่ไหน น้ำหนัก และอะไรที่ยังขาด</string>
|
||||
<string name="widget_atlas_name">แผนที่โลก</string>
|
||||
<string name="widget_atlas_description">ประเทศที่คุณเคยไป ส่องแสงบนแผนที่</string>
|
||||
<string name="widget_collab_name">กลุ่มเดินทาง</string>
|
||||
<string name="widget_collab_description">ใครไปด้วย ข้อความล่าสุด และโหวตที่เปิดอยู่</string>
|
||||
<string name="widget_intent_title">เลือกทริป</string>
|
||||
<string name="widget_intent_description">ทริปที่วิดเจ็ตนี้แสดง</string>
|
||||
<string name="widget_intent_trip">ทริป</string>
|
||||
<string name="widget_intent_auto">อัตโนมัติ</string>
|
||||
<string name="widget_intent_auto_hint">ทริปเดียวกับแดชบอร์ด</string>
|
||||
<string name="share_title">เพิ่มลงใน TREK</string>
|
||||
<string name="share_cancel">ยกเลิก</string>
|
||||
<string name="share_close">ปิด</string>
|
||||
<string name="share_trip">ทริป</string>
|
||||
<string name="share_no_trips">เปิด TREK หนึ่งครั้ง แล้วทริปของคุณจะแสดงที่นี่</string>
|
||||
<string name="share_hint">TREK จะอ่านการจองและเพิ่มลงในทริป</string>
|
||||
<string name="share_later">เลือกภายหลัง</string>
|
||||
<string name="share_add">เพิ่ม</string>
|
||||
<string name="share_add_to">เพิ่มลงใน %1$s</string>
|
||||
<string name="share_added">เพิ่มแล้ว</string>
|
||||
<string name="share_reading">กำลังอ่าน…</string>
|
||||
<string name="share_too_large">ไฟล์นี้ใหญ่เกินไปสำหรับการนำเข้า</string>
|
||||
<string name="share_unsupported">TREK นำเข้ารายการนี้ไม่ได้</string>
|
||||
<string name="share_failed">ส่งต่อให้ TREK ไม่สำเร็จ โปรดลองอีกครั้ง</string>
|
||||
<string name="share_now">ตอนนี้</string>
|
||||
<string name="share_past">ผ่านไปแล้ว</string>
|
||||
<string name="share_tomorrow">พรุ่งนี้</string>
|
||||
<string name="share_in_days">อีก %1$d วัน</string>
|
||||
<string name="share_kind_pass">บัตร Wallet</string>
|
||||
<string name="share_kind_pdf">PDF</string>
|
||||
<string name="share_kind_mail">อีเมล</string>
|
||||
<string name="share_kind_page">หน้าเว็บ</string>
|
||||
<string name="share_kind_text">ข้อความ</string>
|
||||
<string name="share_kind_image">รูปภาพ</string>
|
||||
<string name="share_kind_file">ไฟล์</string>
|
||||
<string name="widget_empty">เปิด TREK หนึ่งครั้ง แล้วทริปของคุณจะแสดงที่นี่</string>
|
||||
<string name="push_channel_chat">แชทการเดินทาง</string>
|
||||
<string name="push_channel_trips">อัปเดตการเดินทาง</string>
|
||||
<string name="push_channel_invites">คำเชิญและการแชร์</string>
|
||||
<string name="push_channel_name">ทั่วไป</string>
|
||||
<string name="push_channel_tripday">วันเดินทาง</string>
|
||||
<string name="push_fallback_body">การแจ้งเตือนใหม่</string>
|
||||
<string name="push_channel_tripday_description">วันของทริปที่กำลังดำเนินอยู่และจุดหมายถัดไป</string>
|
||||
</resources>
|
||||
@@ -1,57 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
|
||||
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
|
||||
<resources>
|
||||
<string name="widget_trip_name">Seyahat</string>
|
||||
<string name="widget_trip_description">Sonraki seyahate kalan günleri sayar, yolculukta günü ve sırada ne olduğunu gösterir.</string>
|
||||
<string name="widget_today_name">Bugün</string>
|
||||
<string name="widget_today_description">Günün planı zaman çizelgesi olarak, hava durumu ve sonraki durakla.</string>
|
||||
<string name="widget_bookings_name">Rezervasyonlar</string>
|
||||
<string name="widget_bookings_description">Sıradaki uçuşların, trenlerin, otellerin ve masaların, ne zaman oldukları ile.</string>
|
||||
<string name="widget_todos_name">Yapılacaklar</string>
|
||||
<string name="widget_todos_description">Seyahat için hâlâ açık olanlar ve bavulun ne durumda olduğu.</string>
|
||||
<string name="widget_packing_name">Bavul</string>
|
||||
<string name="widget_packing_description">Çantaların ne kadar dolu olduğu, ağırlıkları ve eksikler.</string>
|
||||
<string name="widget_atlas_name">Dünya haritası</string>
|
||||
<string name="widget_atlas_description">Bulunduğun ülkeler haritada parlıyor.</string>
|
||||
<string name="widget_collab_name">Seyahat grubu</string>
|
||||
<string name="widget_collab_description">Kimler geliyor, son mesajlar ve açık anket.</string>
|
||||
<string name="widget_intent_title">Seyahat seç</string>
|
||||
<string name="widget_intent_description">Bu widget\'ın gösterdiği seyahat.</string>
|
||||
<string name="widget_intent_trip">Seyahat</string>
|
||||
<string name="widget_intent_auto">Otomatik</string>
|
||||
<string name="widget_intent_auto_hint">Paneldeki seyahatin aynısı</string>
|
||||
<string name="share_title">TREK’e ekle</string>
|
||||
<string name="share_cancel">Vazgeç</string>
|
||||
<string name="share_close">Kapat</string>
|
||||
<string name="share_trip">Seyahat</string>
|
||||
<string name="share_no_trips">TREK’i bir kez açın, seyahatleriniz burada görünür.</string>
|
||||
<string name="share_hint">TREK rezervasyonu okur ve seyahate ekler.</string>
|
||||
<string name="share_later">Sonra seç</string>
|
||||
<string name="share_add">Ekle</string>
|
||||
<string name="share_add_to">%1$s seyahatine ekle</string>
|
||||
<string name="share_added">Eklendi</string>
|
||||
<string name="share_reading">Okunuyor…</string>
|
||||
<string name="share_too_large">Bu dosya içe aktarmak için çok büyük.</string>
|
||||
<string name="share_unsupported">TREK bunu içe aktaramaz.</string>
|
||||
<string name="share_failed">Bu TREK’e aktarılamadı. Lütfen tekrar deneyin.</string>
|
||||
<string name="share_now">Şimdi</string>
|
||||
<string name="share_past">Geçti</string>
|
||||
<string name="share_tomorrow">Yarın</string>
|
||||
<string name="share_in_days">%1$d gün sonra</string>
|
||||
<string name="share_kind_pass">Wallet kartı</string>
|
||||
<string name="share_kind_pdf">PDF</string>
|
||||
<string name="share_kind_mail">E-posta</string>
|
||||
<string name="share_kind_page">Web sayfası</string>
|
||||
<string name="share_kind_text">Metin</string>
|
||||
<string name="share_kind_image">Fotoğraf</string>
|
||||
<string name="share_kind_file">Dosya</string>
|
||||
<string name="widget_empty">TREK’i bir kez açın, seyahatleriniz burada görünür.</string>
|
||||
<string name="push_channel_chat">Seyahat sohbeti</string>
|
||||
<string name="push_channel_trips">Seyahat güncellemeleri</string>
|
||||
<string name="push_channel_invites">Davetler ve paylaşımlar</string>
|
||||
<string name="push_channel_name">Genel</string>
|
||||
<string name="push_channel_tripday">Seyahat günü</string>
|
||||
<string name="push_fallback_body">Yeni bildirim</string>
|
||||
<string name="push_channel_tripday_description">Süren bir seyahatin günü ve sonraki durağı.</string>
|
||||
</resources>
|
||||
@@ -1,57 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
|
||||
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
|
||||
<resources>
|
||||
<string name="widget_trip_name">Подорож</string>
|
||||
<string name="widget_trip_description">Відлічує дні до наступної подорожі, а в дорозі показує день і що далі.</string>
|
||||
<string name="widget_today_name">Сьогодні</string>
|
||||
<string name="widget_today_description">План дня у вигляді стрічки часу, з погодою і наступною зупинкою.</string>
|
||||
<string name="widget_bookings_name">Бронювання</string>
|
||||
<string name="widget_bookings_description">Найближчі рейси, поїзди, готелі й столики та коли вони.</string>
|
||||
<string name="widget_todos_name">Завдання</string>
|
||||
<string name="widget_todos_description">Що ще лишилося зробити до подорожі і як просувається пакування.</string>
|
||||
<string name="widget_packing_name">Пакування</string>
|
||||
<string name="widget_packing_description">Наскільки заповнені сумки, їхня вага і чого ще бракує.</string>
|
||||
<string name="widget_atlas_name">Мапа світу</string>
|
||||
<string name="widget_atlas_description">Країни, де ви були, світяться на мапі.</string>
|
||||
<string name="widget_collab_name">Група подорожі</string>
|
||||
<string name="widget_collab_description">Хто їде, останні повідомлення і відкрите опитування.</string>
|
||||
<string name="widget_intent_title">Вибрати подорож</string>
|
||||
<string name="widget_intent_description">Подорож, яку показує цей віджет.</string>
|
||||
<string name="widget_intent_trip">Подорож</string>
|
||||
<string name="widget_intent_auto">Автоматично</string>
|
||||
<string name="widget_intent_auto_hint">Та сама подорож, що й на панелі</string>
|
||||
<string name="share_title">Додати до TREK</string>
|
||||
<string name="share_cancel">Скасувати</string>
|
||||
<string name="share_close">Закрити</string>
|
||||
<string name="share_trip">Подорож</string>
|
||||
<string name="share_no_trips">Відкрийте TREK один раз, і ваші подорожі з’являться тут.</string>
|
||||
<string name="share_hint">TREK прочитає бронювання й додасть його до подорожі.</string>
|
||||
<string name="share_later">Вибрати пізніше</string>
|
||||
<string name="share_add">Додати</string>
|
||||
<string name="share_add_to">Додати до «%1$s»</string>
|
||||
<string name="share_added">Додано</string>
|
||||
<string name="share_reading">Читання…</string>
|
||||
<string name="share_too_large">Файл завеликий для імпорту.</string>
|
||||
<string name="share_unsupported">TREK не може це імпортувати.</string>
|
||||
<string name="share_failed">Не вдалося передати це до TREK. Повторіть спробу.</string>
|
||||
<string name="share_now">Зараз</string>
|
||||
<string name="share_past">Минула</string>
|
||||
<string name="share_tomorrow">Завтра</string>
|
||||
<string name="share_in_days">Через %1$d дн.</string>
|
||||
<string name="share_kind_pass">Картка Wallet</string>
|
||||
<string name="share_kind_pdf">PDF</string>
|
||||
<string name="share_kind_mail">Лист</string>
|
||||
<string name="share_kind_page">Вебсторінка</string>
|
||||
<string name="share_kind_text">Текст</string>
|
||||
<string name="share_kind_image">Фото</string>
|
||||
<string name="share_kind_file">Файл</string>
|
||||
<string name="widget_empty">Відкрийте TREK один раз, і ваші подорожі з’являться тут.</string>
|
||||
<string name="push_channel_chat">Чат подорожі</string>
|
||||
<string name="push_channel_trips">Новини подорожі</string>
|
||||
<string name="push_channel_invites">Запрошення та доступ</string>
|
||||
<string name="push_channel_name">Загальні</string>
|
||||
<string name="push_channel_tripday">День подорожі</string>
|
||||
<string name="push_fallback_body">Нове сповіщення</string>
|
||||
<string name="push_channel_tripday_description">День поточної подорожі та наступна зупинка.</string>
|
||||
</resources>
|
||||
@@ -1,57 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
|
||||
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
|
||||
<resources>
|
||||
<string name="widget_trip_name">Chuyến đi</string>
|
||||
<string name="widget_trip_description">Đếm ngược đến chuyến đi tiếp theo, trong chuyến đi thì hiện ngày và điểm tiếp theo.</string>
|
||||
<string name="widget_today_name">Hôm nay</string>
|
||||
<string name="widget_today_description">Kế hoạch hôm nay dạng dòng thời gian, kèm thời tiết và điểm dừng tiếp theo.</string>
|
||||
<string name="widget_bookings_name">Đặt chỗ</string>
|
||||
<string name="widget_bookings_description">Các chuyến bay, tàu, khách sạn và bàn sắp tới của bạn, cùng thời gian.</string>
|
||||
<string name="widget_todos_name">Việc cần làm</string>
|
||||
<string name="widget_todos_description">Những gì còn lại cho chuyến đi và việc soạn hành lý đã tới đâu.</string>
|
||||
<string name="widget_packing_name">Hành lý</string>
|
||||
<string name="widget_packing_description">Túi đã đầy đến đâu, cân nặng và những gì còn thiếu.</string>
|
||||
<string name="widget_atlas_name">Bản đồ thế giới</string>
|
||||
<string name="widget_atlas_description">Những quốc gia bạn đã đến, sáng lên trên bản đồ.</string>
|
||||
<string name="widget_collab_name">Nhóm du lịch</string>
|
||||
<string name="widget_collab_description">Ai đi cùng, tin nhắn mới nhất và cuộc bình chọn đang mở.</string>
|
||||
<string name="widget_intent_title">Chọn chuyến đi</string>
|
||||
<string name="widget_intent_description">Chuyến đi mà tiện ích này hiển thị.</string>
|
||||
<string name="widget_intent_trip">Chuyến đi</string>
|
||||
<string name="widget_intent_auto">Tự động</string>
|
||||
<string name="widget_intent_auto_hint">Cùng chuyến đi với bảng điều khiển</string>
|
||||
<string name="share_title">Thêm vào TREK</string>
|
||||
<string name="share_cancel">Hủy</string>
|
||||
<string name="share_close">Đóng</string>
|
||||
<string name="share_trip">Chuyến đi</string>
|
||||
<string name="share_no_trips">Mở TREK một lần, các chuyến đi của bạn sẽ hiện ở đây.</string>
|
||||
<string name="share_hint">TREK đọc đặt chỗ và thêm vào chuyến đi.</string>
|
||||
<string name="share_later">Chọn sau</string>
|
||||
<string name="share_add">Thêm</string>
|
||||
<string name="share_add_to">Thêm vào %1$s</string>
|
||||
<string name="share_added">Đã thêm</string>
|
||||
<string name="share_reading">Đang đọc…</string>
|
||||
<string name="share_too_large">Tệp này quá lớn để nhập.</string>
|
||||
<string name="share_unsupported">TREK không thể nhập nội dung này.</string>
|
||||
<string name="share_failed">Không thể chuyển sang TREK. Vui lòng thử lại.</string>
|
||||
<string name="share_now">Đang diễn ra</string>
|
||||
<string name="share_past">Đã qua</string>
|
||||
<string name="share_tomorrow">Ngày mai</string>
|
||||
<string name="share_in_days">Sau %1$d ngày</string>
|
||||
<string name="share_kind_pass">Thẻ Wallet</string>
|
||||
<string name="share_kind_pdf">PDF</string>
|
||||
<string name="share_kind_mail">Email</string>
|
||||
<string name="share_kind_page">Trang web</string>
|
||||
<string name="share_kind_text">Văn bản</string>
|
||||
<string name="share_kind_image">Ảnh</string>
|
||||
<string name="share_kind_file">Tệp</string>
|
||||
<string name="widget_empty">Mở TREK một lần, các chuyến đi của bạn sẽ hiện ở đây.</string>
|
||||
<string name="push_channel_chat">Trò chuyện chuyến đi</string>
|
||||
<string name="push_channel_trips">Cập nhật chuyến đi</string>
|
||||
<string name="push_channel_invites">Lời mời và chia sẻ</string>
|
||||
<string name="push_channel_name">Chung</string>
|
||||
<string name="push_channel_tripday">Ngày đi</string>
|
||||
<string name="push_fallback_body">Thông báo mới</string>
|
||||
<string name="push_channel_tripday_description">Ngày của chuyến đi đang diễn ra và điểm dừng tiếp theo.</string>
|
||||
</resources>
|
||||
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- A stop without a category colour in the travel-day progress bar. -->
|
||||
<color name="tripday_segment">#4A7DDB</color>
|
||||
<!-- The tint of TREK's notifications: the app's ink, readable on light and dark shades. -->
|
||||
<color name="notification_accent">#4A7DDB</color>
|
||||
</resources>
|
||||
@@ -1,13 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The Firebase project the app receives push through (liketrek push relay).
|
||||
These are the strings the google-services Gradle plugin would generate from
|
||||
google-services.json; Firebase reads them at start. None of them is a secret:
|
||||
they identify the project, sending needs the relay's service account. -->
|
||||
<resources>
|
||||
<string name="google_app_id" translatable="false">1:1074454522965:android:3ff2fe270a27a73051b8dc</string>
|
||||
<string name="gcm_defaultSenderId" translatable="false">1074454522965</string>
|
||||
<string name="google_api_key" translatable="false">AIzaSyB2VYXmZ90BZuksHhHR_n2W8naNE_vJAJA</string>
|
||||
<string name="google_crash_reporting_api_key" translatable="false">AIzaSyB2VYXmZ90BZuksHhHR_n2W8naNE_vJAJA</string>
|
||||
<string name="project_id" translatable="false">trek-9392e</string>
|
||||
<string name="google_storage_bucket" translatable="false">trek-9392e.firebasestorage.app</string>
|
||||
</resources>
|
||||
@@ -1,8 +1,7 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name" translatable="false">TREK</string>
|
||||
<string name="title_activity_main" translatable="false">TREK</string>
|
||||
<string name="package_name" translatable="false">com.liketrek.trek</string>
|
||||
<string name="custom_url_scheme" translatable="false">com.liketrek.trek</string>
|
||||
<string name="push_fallback_title" translatable="false">TREK</string>
|
||||
<string name="app_name">TREK</string>
|
||||
<string name="title_activity_main">TREK</string>
|
||||
<string name="package_name">com.liketrek.trek</string>
|
||||
<string name="custom_url_scheme">com.liketrek.trek</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
|
||||
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
|
||||
<resources>
|
||||
<string name="widget_trip_name">Trip</string>
|
||||
<string name="widget_trip_description">Counts down to your next trip, then shows the day and what comes next.</string>
|
||||
<string name="widget_today_name">Today</string>
|
||||
<string name="widget_today_description">Today\'s plan as a timeline, with the weather and what is next.</string>
|
||||
<string name="widget_bookings_name">Bookings</string>
|
||||
<string name="widget_bookings_description">Your next flights, trains, hotels and tables, and when they are due.</string>
|
||||
<string name="widget_todos_name">To-dos</string>
|
||||
<string name="widget_todos_description">What is still open for the trip, and how far the packing has come.</string>
|
||||
<string name="widget_packing_name">Packing</string>
|
||||
<string name="widget_packing_description">How full the bags are, their weight, and what is still missing.</string>
|
||||
<string name="widget_atlas_name">World map</string>
|
||||
<string name="widget_atlas_description">The countries you have been to, glowing on the map.</string>
|
||||
<string name="widget_collab_name">Travel group</string>
|
||||
<string name="widget_collab_description">Who comes along, the latest messages and the open poll.</string>
|
||||
<string name="widget_intent_title">Choose trip</string>
|
||||
<string name="widget_intent_description">The trip this widget shows.</string>
|
||||
<string name="widget_intent_trip">Trip</string>
|
||||
<string name="widget_intent_auto">Automatic</string>
|
||||
<string name="widget_intent_auto_hint">Same trip as the dashboard</string>
|
||||
<string name="share_title">Add to TREK</string>
|
||||
<string name="share_cancel">Cancel</string>
|
||||
<string name="share_close">Close</string>
|
||||
<string name="share_trip">Trip</string>
|
||||
<string name="share_no_trips">Open TREK once and your trips show up here.</string>
|
||||
<string name="share_hint">TREK reads the booking and adds it to the trip.</string>
|
||||
<string name="share_later">Choose later</string>
|
||||
<string name="share_add">Add</string>
|
||||
<string name="share_add_to">Add to %1$s</string>
|
||||
<string name="share_added">Added</string>
|
||||
<string name="share_reading">Reading…</string>
|
||||
<string name="share_too_large">This file is too large to import.</string>
|
||||
<string name="share_unsupported">TREK cannot import this.</string>
|
||||
<string name="share_failed">This could not be handed to TREK. Please try again.</string>
|
||||
<string name="share_now">Now</string>
|
||||
<string name="share_past">Past</string>
|
||||
<string name="share_tomorrow">Tomorrow</string>
|
||||
<string name="share_in_days">In %1$d days</string>
|
||||
<string name="share_kind_pass">Wallet pass</string>
|
||||
<string name="share_kind_pdf">PDF</string>
|
||||
<string name="share_kind_mail">Email</string>
|
||||
<string name="share_kind_page">Web page</string>
|
||||
<string name="share_kind_text">Text</string>
|
||||
<string name="share_kind_image">Photo</string>
|
||||
<string name="share_kind_file">File</string>
|
||||
<string name="widget_empty">Open TREK once and your trips show up here.</string>
|
||||
<string name="push_channel_chat">Trip chat</string>
|
||||
<string name="push_channel_trips">Trip updates</string>
|
||||
<string name="push_channel_invites">Invitations and shares</string>
|
||||
<string name="push_channel_name">General</string>
|
||||
<string name="push_channel_tripday">Travel day</string>
|
||||
<string name="push_fallback_body">New notification</string>
|
||||
<string name="push_channel_tripday_description">The day of a running trip and its next stop.</string>
|
||||
</resources>
|
||||
@@ -16,19 +16,6 @@
|
||||
</style>
|
||||
|
||||
|
||||
<!-- The app's own Compose screens; Compose paints the rest. -->
|
||||
<style name="AppTheme.Native" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="android:windowBackground">@android:color/transparent</item>
|
||||
</style>
|
||||
|
||||
<!-- The share sheet: a see-through window the bottom sheet rises over. -->
|
||||
<style name="AppTheme.Sheet" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="android:windowIsTranslucent">true</item>
|
||||
<item name="android:windowBackground">@android:color/transparent</item>
|
||||
<item name="android:windowAnimationStyle">@null</item>
|
||||
<item name="android:backgroundDimEnabled">false</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||
<item name="android:background">@drawable/splash</item>
|
||||
<!-- Android 12+ draws its own launch screen: the app icon on this colour.
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The trips as direct share targets: the share sheet offers the trip shortcuts
|
||||
TrekShortcuts publishes (category TRIP_SHARE) for these types, and a tap opens
|
||||
the share sheet with that trip already picked. -->
|
||||
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<share-target android:targetClass="com.liketrek.trek.share.ShareActivity">
|
||||
<data android:mimeType="application/pdf" />
|
||||
<data android:mimeType="application/vnd.apple.pkpass" />
|
||||
<data android:mimeType="message/rfc822" />
|
||||
<data android:mimeType="text/calendar" />
|
||||
<data android:mimeType="text/html" />
|
||||
<data android:mimeType="text/plain" />
|
||||
<data android:mimeType="image/*" />
|
||||
<category android:name="com.liketrek.trek.category.TRIP_SHARE" />
|
||||
</share-target>
|
||||
</shortcuts>
|
||||
@@ -1,17 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The atlas widget (widget/TrekWidgets.kt). Sizes in cells for Android 12+, dp before. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/widget_atlas_description"
|
||||
android:initialLayout="@layout/glance_default_loading_layout"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:maxResizeWidth="530dp"
|
||||
android:maxResizeHeight="450dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen"
|
||||
android:previewImage="@drawable/widget_preview_atlas" />
|
||||
@@ -1,19 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The bookings widget (widget/TrekWidgets.kt). Sizes in cells for Android 12+, dp before. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/widget_bookings_description"
|
||||
android:initialLayout="@layout/glance_default_loading_layout"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:maxResizeWidth="530dp"
|
||||
android:maxResizeHeight="450dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen"
|
||||
android:configure="com.liketrek.trek.widget.WidgetConfigActivity"
|
||||
android:widgetFeatures="reconfigurable|configuration_optional"
|
||||
android:previewImage="@drawable/widget_preview_bookings" />
|
||||
@@ -1,19 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The collab widget (widget/TrekWidgets.kt). Sizes in cells for Android 12+, dp before. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/widget_collab_description"
|
||||
android:initialLayout="@layout/glance_default_loading_layout"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:maxResizeWidth="530dp"
|
||||
android:maxResizeHeight="450dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen"
|
||||
android:configure="com.liketrek.trek.widget.WidgetConfigActivity"
|
||||
android:widgetFeatures="reconfigurable|configuration_optional"
|
||||
android:previewImage="@drawable/widget_preview_collab" />
|
||||
@@ -1,19 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The packing widget (widget/TrekWidgets.kt). Sizes in cells for Android 12+, dp before. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/widget_packing_description"
|
||||
android:initialLayout="@layout/glance_default_loading_layout"
|
||||
android:minWidth="110dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:maxResizeWidth="530dp"
|
||||
android:maxResizeHeight="450dp"
|
||||
android:targetCellWidth="2"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen"
|
||||
android:configure="com.liketrek.trek.widget.WidgetConfigActivity"
|
||||
android:widgetFeatures="reconfigurable|configuration_optional"
|
||||
android:previewImage="@drawable/widget_preview_packing" />
|
||||
@@ -1,19 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The today widget (widget/TrekWidgets.kt). Sizes in cells for Android 12+, dp before. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/widget_today_description"
|
||||
android:initialLayout="@layout/glance_default_loading_layout"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:maxResizeWidth="530dp"
|
||||
android:maxResizeHeight="450dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen"
|
||||
android:configure="com.liketrek.trek.widget.WidgetConfigActivity"
|
||||
android:widgetFeatures="reconfigurable|configuration_optional"
|
||||
android:previewImage="@drawable/widget_preview_today" />
|
||||
@@ -1,19 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The todos widget (widget/TrekWidgets.kt). Sizes in cells for Android 12+, dp before. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/widget_todos_description"
|
||||
android:initialLayout="@layout/glance_default_loading_layout"
|
||||
android:minWidth="110dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:maxResizeWidth="530dp"
|
||||
android:maxResizeHeight="450dp"
|
||||
android:targetCellWidth="2"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen"
|
||||
android:configure="com.liketrek.trek.widget.WidgetConfigActivity"
|
||||
android:widgetFeatures="reconfigurable|configuration_optional"
|
||||
android:previewImage="@drawable/widget_preview_todos" />
|
||||
@@ -1,19 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The trip widget (widget/TrekWidgets.kt). Sizes in cells for Android 12+, dp before. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/widget_trip_description"
|
||||
android:initialLayout="@layout/glance_default_loading_layout"
|
||||
android:minWidth="110dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:maxResizeWidth="530dp"
|
||||
android:maxResizeHeight="450dp"
|
||||
android:targetCellWidth="2"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen"
|
||||
android:configure="com.liketrek.trek.widget.WidgetConfigActivity"
|
||||
android:widgetFeatures="reconfigurable|configuration_optional"
|
||||
android:previewImage="@drawable/widget_preview_trip" />
|
||||
@@ -1,161 +0,0 @@
|
||||
package com.liketrek.trek
|
||||
|
||||
import android.view.KeyEvent
|
||||
import com.android.billingclient.api.Purchase
|
||||
import com.liketrek.trek.billing.TrekBilling
|
||||
import com.liketrek.trek.screen.TrekKeyCommands
|
||||
import com.liketrek.trek.screen.TrekWindowLayout
|
||||
import com.liketrek.trek.share.ShareHandoff
|
||||
import com.liketrek.trek.share.ShareItem
|
||||
import com.liketrek.trek.share.ShareLoader
|
||||
import com.liketrek.trek.tiles.TrekTileStore
|
||||
import java.time.LocalDate
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/** The native side's own decisions, apart from the views. */
|
||||
class NativeLogicTest {
|
||||
@Test
|
||||
fun pushesLandInTheChannelOfTheirEvent() {
|
||||
assertEquals(TrekPushService.CHANNEL_CHAT, TrekPushService.channelFor("collab_message"))
|
||||
assertEquals(TrekPushService.CHANNEL_TRIPS, TrekPushService.channelFor("booking_change"))
|
||||
assertEquals(TrekPushService.CHANNEL_TRIPS, TrekPushService.channelFor("todo_due"))
|
||||
assertEquals(TrekPushService.CHANNEL_INVITES, TrekPushService.channelFor("trip_invite"))
|
||||
assertEquals(TrekPushService.CHANNEL, TrekPushService.channelFor("version_available"))
|
||||
// A server too old to name the event: the general channel.
|
||||
assertEquals(TrekPushService.CHANNEL, TrekPushService.channelFor(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sharedFilesAreSortedByWhatTheyAre() {
|
||||
assertEquals(ShareItem.Kind.PASS to "application/vnd.apple.pkpass", ShareLoader.classify("Boarding.pkpass", "application/octet-stream"))
|
||||
assertEquals(ShareItem.Kind.PDF to "application/pdf", ShareLoader.classify("ticket", "application/pdf"))
|
||||
assertEquals(ShareItem.Kind.MAIL to "message/rfc822", ShareLoader.classify("Booking.eml", "application/octet-stream"))
|
||||
assertEquals(ShareItem.Kind.CALENDAR to "text/calendar", ShareLoader.classify("invite.ics", ""))
|
||||
assertEquals(ShareItem.Kind.IMAGE to "image/png", ShareLoader.classify("shot.png", "image/png"))
|
||||
assertEquals(ShareItem.Kind.FILE to "application/zip", ShareLoader.classify("a.zip", "application/zip"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSharedLinkIsFetchedOnlyWhenTheTextIsLittleMore() {
|
||||
assertEquals("https://example.com/booking/1", ShareLoader.firstLink("Your booking: https://example.com/booking/1."))
|
||||
assertNull(ShareLoader.firstLink("no link here"))
|
||||
assertNull(ShareLoader.firstLink("x".repeat(400) + " https://example.com/a"))
|
||||
assertEquals("Hotel Kanra", ShareLoader.pageTitle("<html><head><TITLE> Hotel Kanra </TITLE></head></html>"))
|
||||
assertEquals("hotel-kanra-kyoto", ShareLoader.slug("Hotel Kanra, Kyoto!"))
|
||||
assertEquals("page", ShareLoader.slug("!!!"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theShareSheetOffersComingTripsFirstThenRecentPastOnes() {
|
||||
val today = LocalDate.of(2026, 11, 7)
|
||||
fun trip(id: Long, start: String, end: String) =
|
||||
ShareHandoff.Trip(id, "T$id", LocalDate.parse(start), LocalDate.parse(end), null, null)
|
||||
val past = (1L..8L).map { trip(100 + it, "2026-0$it-01", "2026-0$it-05") }
|
||||
val ordered = ShareHandoff.order(
|
||||
listOf(trip(1, "2026-12-01", "2026-12-05"), trip(2, "2026-11-05", "2026-11-09")) + past,
|
||||
today,
|
||||
)
|
||||
assertEquals(listOf(2L, 1L), ordered.take(2).map { it.id })
|
||||
assertEquals(8, ordered.size)
|
||||
assertEquals(108L, ordered[2].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keyCommandsFollowTheUi() {
|
||||
TrekKeyCommands.set(
|
||||
JSONArray(
|
||||
"""[{"id":"search","title":"Search","input":"k","modifiers":["command"]},
|
||||
{"id":"back","title":"Back","input":"[","modifiers":["command"]},
|
||||
{"id":"close","title":"Close","input":"escape","modifiers":[]},
|
||||
{"title":"no id","input":"x"}]""",
|
||||
),
|
||||
)
|
||||
assertEquals(3, TrekKeyCommands.current.size)
|
||||
assertEquals("search", TrekKeyCommands.match(KeyEvent.KEYCODE_K, KeyEvent.META_CTRL_ON)?.id)
|
||||
// A Mac keyboard's Command key is Meta here, and counts as Ctrl.
|
||||
assertEquals("search", TrekKeyCommands.match(KeyEvent.KEYCODE_K, KeyEvent.META_META_ON)?.id)
|
||||
assertNull(TrekKeyCommands.match(KeyEvent.KEYCODE_K, 0))
|
||||
assertNull(TrekKeyCommands.match(KeyEvent.KEYCODE_K, KeyEvent.META_CTRL_ON or KeyEvent.META_SHIFT_ON))
|
||||
assertEquals("back", TrekKeyCommands.match(KeyEvent.KEYCODE_LEFT_BRACKET, KeyEvent.META_CTRL_ON)?.id)
|
||||
assertEquals("close", TrekKeyCommands.match(KeyEvent.KEYCODE_ESCAPE, 0)?.id)
|
||||
TrekKeyCommands.set(null)
|
||||
assertEquals(0, TrekKeyCommands.current.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun windowSizeClassesUseAndroidsBreakpoints() {
|
||||
assertEquals("compact", TrekWindowLayout.sizeClass(411f, 600f, 840f))
|
||||
assertEquals("medium", TrekWindowLayout.sizeClass(673f, 600f, 840f))
|
||||
assertEquals("expanded", TrekWindowLayout.sizeClass(1280f, 600f, 840f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRestoreTakesTheNewestActiveCloudSubscription() {
|
||||
fun purchase(product: String, time: Long, state: Int) = Purchase(
|
||||
JSONObject()
|
||||
.put("productIds", JSONArray().put(product))
|
||||
.put("purchaseTime", time)
|
||||
.put("purchaseState", state)
|
||||
.put("purchaseToken", "token-$product-$time")
|
||||
.toString(),
|
||||
"sig",
|
||||
)
|
||||
val found = TrekBilling.newest(
|
||||
listOf(
|
||||
purchase("cloud.start.monthly", 1000, 0),
|
||||
purchase("cloud.plus.yearly", 2000, 0),
|
||||
purchase("cloud.pro.yearly", 3000, 4),
|
||||
purchase("other.thing", 4000, 0),
|
||||
),
|
||||
"cloud.",
|
||||
)
|
||||
assertEquals("token-cloud.plus.yearly-2000", found?.purchaseToken)
|
||||
assertNull(TrekBilling.newest(emptyList(), "cloud."))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mapboxRequestsKeepTheWebViewsHeaders() {
|
||||
val forwarded = TrekTileStore.forwardedHeaders(
|
||||
mapOf(
|
||||
"Referer" to "https://trek.example.com/",
|
||||
"Origin" to "https://trek.example.com",
|
||||
"User-Agent" to "Mozilla/5.0 (Linux; Android 16; wv)",
|
||||
"Accept" to "application/x-protobuf",
|
||||
"Accept-Encoding" to "gzip",
|
||||
"Connection" to "keep-alive",
|
||||
"Host" to "api.mapbox.com",
|
||||
),
|
||||
)
|
||||
// A URL-restricted token is checked against the Referer, so it has to go along.
|
||||
assertEquals("https://trek.example.com/", forwarded["Referer"])
|
||||
assertEquals("https://trek.example.com", forwarded["Origin"])
|
||||
assertEquals("Mozilla/5.0 (Linux; Android 16; wv)", forwarded["User-Agent"])
|
||||
assertEquals("application/x-protobuf", forwarded["Accept"])
|
||||
assertNull(forwarded["Accept-Encoding"])
|
||||
assertNull(forwarded["Connection"])
|
||||
assertNull(forwarded["Host"])
|
||||
assertEquals(emptyMap<String, String>(), TrekTileStore.forwardedHeaders(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPaymentThatClearedLaterIsTheUndeliveredOne() {
|
||||
fun purchase(product: String, time: Long, acknowledged: Boolean) = Purchase(
|
||||
JSONObject()
|
||||
.put("productIds", JSONArray().put(product))
|
||||
.put("purchaseTime", time)
|
||||
.put("purchaseState", 0)
|
||||
.put("acknowledged", acknowledged)
|
||||
.put("purchaseToken", "token-$time")
|
||||
.toString(),
|
||||
"sig",
|
||||
)
|
||||
val renewed = purchase("cloud.plus.yearly", 3000, acknowledged = true)
|
||||
val cleared = purchase("cloud.start.monthly", 2000, acknowledged = false)
|
||||
assertEquals("token-2000", TrekBilling.undelivered(listOf(renewed, cleared), "cloud.")?.purchaseToken)
|
||||
assertNull(TrekBilling.undelivered(listOf(renewed), "cloud."))
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package com.liketrek.trek;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.interfaces.ECPrivateKey;
|
||||
import java.security.interfaces.ECPublicKey;
|
||||
import java.security.spec.ECGenParameterSpec;
|
||||
import java.security.spec.ECPrivateKeySpec;
|
||||
import java.util.Base64;
|
||||
import org.junit.Test;
|
||||
|
||||
/** The decryption against the worked example in RFC 8291, section 5. */
|
||||
public class TrekWebPushTest {
|
||||
|
||||
private static byte[] b64(String value) {
|
||||
return Base64.getUrlDecoder().decode(value);
|
||||
}
|
||||
|
||||
private static TrekPushKeys rfcKeys() throws Exception {
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
|
||||
generator.initialize(new ECGenParameterSpec("secp256r1"));
|
||||
ECPublicKey anyKey = (ECPublicKey) generator.generateKeyPair().getPublic();
|
||||
BigInteger d = new BigInteger(1, b64("q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94"));
|
||||
ECPrivateKey privateKey = (ECPrivateKey) KeyFactory.getInstance("EC").generatePrivate(new ECPrivateKeySpec(d, anyKey.getParams()));
|
||||
return new TrekPushKeys(
|
||||
privateKey,
|
||||
b64("BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"),
|
||||
b64("BTBZMqHH6r4Tts7J_aSIgg")
|
||||
);
|
||||
}
|
||||
|
||||
private static final String RECORD =
|
||||
"DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPTpK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN";
|
||||
|
||||
@Test
|
||||
public void decryptsTheRfcExample() throws Exception {
|
||||
byte[] plain = TrekWebPush.decrypt(b64(RECORD), rfcKeys());
|
||||
assertEquals("When I grow up, I want to be a watermelon", new String(plain, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void refusesATamperedRecord() throws Exception {
|
||||
byte[] record = b64(RECORD);
|
||||
record[record.length - 1] ^= 1;
|
||||
assertThrows(Exception.class, () -> TrekWebPush.decrypt(record, rfcKeys()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void writesThePublicKeyAsAFixedLengthPoint() throws Exception {
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
|
||||
generator.initialize(new ECGenParameterSpec("secp256r1"));
|
||||
for (int i = 0; i < 20; i++) {
|
||||
byte[] point = TrekPushKeys.uncompressed((ECPublicKey) generator.generateKeyPair().getPublic());
|
||||
assertEquals(65, point.length);
|
||||
assertEquals(4, point[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
package com.liketrek.trek.widget
|
||||
|
||||
import java.time.LocalDate
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class WidgetSnapshotTest {
|
||||
private val today = LocalDate.of(2026, 11, 7)
|
||||
|
||||
@Test
|
||||
fun readsTheSampleTheIosWidgetsReadToo() {
|
||||
val snapshot = WidgetSnapshot.parse(WidgetSamples.json(running = true, now = today))!!
|
||||
assertEquals("Autumn in Kyoto", snapshot.trip!!.title)
|
||||
assertEquals("/trips/1", snapshot.trip!!.path)
|
||||
assertEquals(5, snapshot.today!!.stops.size)
|
||||
assertEquals(810, snapshot.today!!.stops[2].minutes)
|
||||
assertEquals("leaf.fill", snapshot.today!!.stops[2].symbol)
|
||||
assertEquals(5, snapshot.bookings.size)
|
||||
assertTrue(snapshot.bookings[0].confirmed)
|
||||
assertEquals(2, snapshot.packing!!.bags.size)
|
||||
assertEquals(23000, snapshot.packing!!.bags[0].limit)
|
||||
assertEquals(23, snapshot.atlas!!.count)
|
||||
assertEquals("Nara", snapshot.collab!!.poll!!.options[0].label)
|
||||
assertTrue(snapshot.collab!!.messages.last().mine)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everythingPastTripTodayAndLabelsIsOptional() {
|
||||
val snapshot = WidgetSnapshot.parse("""{"trip":null,"today":null,"labels":{"nextTrip":"Nächste Reise"}}""")!!
|
||||
assertNull(snapshot.trip)
|
||||
assertNull(snapshot.weather)
|
||||
assertTrue(snapshot.bookings.isEmpty())
|
||||
assertEquals("Nächste Reise", snapshot.labels.nextTrip)
|
||||
// A label the server's UI does not send yet falls back to English.
|
||||
assertEquals("Travel group", snapshot.labels.group)
|
||||
assertEquals("Today", snapshot.labels.todayShort)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun brokenJsonIsNoSnapshot() {
|
||||
assertNull(WidgetSnapshot.parse("{"))
|
||||
assertNull(WidgetSnapshot.parse(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun countsTheDaysToTheTripAndItsLength() {
|
||||
val snapshot = WidgetSnapshot.parse(WidgetSamples.json(running = false, now = today))!!
|
||||
assertEquals(12L, snapshot.daysUntilStart(today))
|
||||
assertEquals(9, snapshot.tripLength)
|
||||
val noCount = WidgetSnapshot.parse("""{"trip":{"title":"T","start":"2026-11-10","end":"2026-11-12","path":"/trips/2"},"labels":{}}""")!!
|
||||
assertEquals(3, noCount.tripLength)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aTapOpensThePathInTheApp() {
|
||||
assertEquals("com.liketrek.trek://navigate/trips/1", WidgetSnapshot.link("/trips/1"))
|
||||
assertEquals("com.liketrek.trek://navigate/atlas", WidgetSnapshot.link("atlas"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readsTheTripListAndSkipsWhatIsNotATrip() {
|
||||
val trips = TripRef.parseList("""[{"id":1,"title":"A","start":"2026-11-01","end":"2026-11-03"},{"title":"no id"},3]""")
|
||||
assertEquals(1, trips.size)
|
||||
assertEquals(1L, trips[0].id)
|
||||
assertTrue(TripRef.parseList("not json").isEmpty())
|
||||
assertTrue(TripRef.parseList(null).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keysAreTheOnesTheUiWrites() {
|
||||
assertTrue(WidgetStore.isValidKey("snapshot"))
|
||||
assertTrue(WidgetStore.isValidKey("trips"))
|
||||
assertTrue(WidgetStore.isValidKey("trip.42"))
|
||||
assertFalse(WidgetStore.isValidKey("trip.../x"))
|
||||
assertFalse(WidgetStore.isValidKey("../snapshot"))
|
||||
assertFalse(WidgetStore.isValidKey("trip.1234567890123"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theWorldGridHasItsShape() {
|
||||
assertEquals(34, WorldGrid.ROWS.size)
|
||||
WorldGrid.ROWS.forEach { assertEquals(WorldGrid.COLUMNS * 2, it.length) }
|
||||
assertNotNull((0 until 34).flatMap { r -> (0 until WorldGrid.COLUMNS).mapNotNull { WorldGrid.code(r, it) } }.firstOrNull { it == "JP" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aTripKeepsItsColoursAcrossLaunches() {
|
||||
assertEquals(WidgetArt.seed("Autumn in Kyoto"), WidgetArt.seed("Autumn in Kyoto"))
|
||||
assertFalse(WidgetArt.seed("Lisbon") == WidgetArt.seed("Kyoto"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bookingKindsHaveTheirSymbol() {
|
||||
assertEquals("airplane", TrekIcons.booking("flight").first)
|
||||
assertEquals("ferry.fill", TrekIcons.booking("cruise").first)
|
||||
assertEquals("bookmark.fill", TrekIcons.booking("something").first)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,13 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
ext.kotlin_version = '2.2.21'
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.13.0'
|
||||
// Kotlin and the Compose compiler, for the native surfaces written in
|
||||
// Compose: the Glance home-screen widgets and the share sheet.
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
classpath "org.jetbrains.kotlin:compose-compiler-gradle-plugin:$kotlin_version"
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
|
||||
@@ -15,9 +15,4 @@ ext {
|
||||
cordovaAndroidVersion = '14.0.1'
|
||||
// Custom Tabs for SSO and passkey sign-in (TrekShellPlugin.authenticate).
|
||||
androidxBrowserVersion = '1.8.0'
|
||||
// The native surfaces: home-screen widgets (Glance), the share sheet and the
|
||||
// widget's trip picker (Compose, Material 3), and Google Play Billing.
|
||||
composeBomVersion = '2025.10.01'
|
||||
glanceVersion = '1.2.0'
|
||||
playBillingVersion = '8.3.0'
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { expect, test, type Locator } from '@playwright/test';
|
||||
|
||||
// #2981: labels, category and status took the whole row, so a place with two or
|
||||
// three long labels showed one letter of its name or none, and the status pill
|
||||
// ran out of the card. Measured in a real browser because the layout is the bug.
|
||||
const PLACES = [
|
||||
{ name: 'Oceanario de Lisboa', labels: ['Core shortlist', 'Heritage specialist', 'Pair with another stop'] },
|
||||
{ name: 'Cove of Ursa', labels: ['Heritage specialist', 'Pair with another stop'] },
|
||||
{ name: 'A Vida Portuguesa', labels: ['Flagship', 'Market', 'Core shortlist', 'Pair with another stop'] },
|
||||
{ name: 'Torre de Belem', labels: [] as string[] },
|
||||
];
|
||||
|
||||
async function expectInside(row: Locator) {
|
||||
const escapes = await row.evaluate((el) => {
|
||||
const box = el.getBoundingClientRect();
|
||||
return Array.from(el.querySelectorAll('*')).filter((child) => {
|
||||
// The strip's hidden measuring copy is meant to run past the bar.
|
||||
if (child.closest('[aria-hidden="true"]')) return false;
|
||||
const r = child.getBoundingClientRect();
|
||||
return r.width > 0 && (r.left < box.left - 0.5 || r.right > box.right + 0.5);
|
||||
}).length;
|
||||
});
|
||||
expect(escapes).toBe(0);
|
||||
}
|
||||
|
||||
for (const viewport of [
|
||||
{ width: 1920, height: 1000 },
|
||||
{ width: 1400, height: 900 },
|
||||
{ width: 1024, height: 800 },
|
||||
]) {
|
||||
test(`place names stay readable next to long labels at ${viewport.width}px`, async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
await page.setViewportSize(viewport);
|
||||
const addonsResponse = await page.request.get('/api/admin/addons');
|
||||
expect(addonsResponse.ok()).toBeTruthy();
|
||||
const { addons } = await addonsResponse.json();
|
||||
const original = addons.find((addon: { id: string }) => addon.id === 'collections');
|
||||
expect(original).toBeDefined();
|
||||
let collectionId: number | undefined;
|
||||
|
||||
try {
|
||||
expect((await page.request.put('/api/admin/addons/collections', { data: { enabled: true } })).ok()).toBeTruthy();
|
||||
const created = await page.request.post('/api/addons/collections', { data: { name: `Rows ${viewport.width}` } });
|
||||
expect(created.ok()).toBeTruthy();
|
||||
const body = await created.json();
|
||||
collectionId = (body.collection ?? body).id as number;
|
||||
|
||||
const labelIds = new Map<string, number>();
|
||||
for (const name of new Set(PLACES.flatMap((p) => p.labels))) {
|
||||
const res = await page.request.post('/api/addons/collections/labels', {
|
||||
data: { collection_id: collectionId, name, color: '#2563eb' },
|
||||
});
|
||||
expect(res.ok(), `label ${name}`).toBeTruthy();
|
||||
const label = await res.json();
|
||||
labelIds.set(name, (label.label ?? label).id);
|
||||
}
|
||||
for (const place of PLACES) {
|
||||
const res = await page.request.post('/api/addons/collections/places', {
|
||||
data: {
|
||||
collection_id: collectionId,
|
||||
name: place.name,
|
||||
address: 'Rua Anchieta 11, 1200-023 Lisboa, Portugal',
|
||||
lat: 38.71,
|
||||
lng: -9.14,
|
||||
},
|
||||
});
|
||||
expect(res.ok(), `place ${place.name}`).toBeTruthy();
|
||||
const saved = await res.json();
|
||||
if (!place.labels.length) continue;
|
||||
const assign = await page.request.post('/api/addons/collections/labels/assign', {
|
||||
data: { label_ids: place.labels.map((l) => labelIds.get(l)), place_ids: [(saved.place ?? saved).id] },
|
||||
});
|
||||
expect(assign.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
await page.goto(`/collections/${collectionId}`);
|
||||
const rows = page.locator('.col-lrow');
|
||||
await expect(rows).toHaveCount(PLACES.length);
|
||||
for (const place of PLACES) {
|
||||
const row = rows.filter({ hasText: place.name });
|
||||
const name = row.locator('.li .t');
|
||||
await expect(name).toHaveText(place.name);
|
||||
// The whole name fits: it is not cut to an ellipsis.
|
||||
expect(await name.evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
|
||||
await expectInside(row);
|
||||
// Every label is either on the row or counted by its "+N" chip.
|
||||
const shown = await row.locator('.col-lrow-label:not(.is-over)').count();
|
||||
// Read without waiting: a row whose labels all fit has no "+N".
|
||||
const [more] = await row.locator('.col-lrow-more').allTextContents();
|
||||
expect(shown + (more ? Number(more.replace('+', '')) : 0)).toBe(place.labels.length);
|
||||
}
|
||||
// The map's label strip stays inside the map: whole, or folded into its button.
|
||||
const topbar = page.locator('.col-map-topbar');
|
||||
if (await topbar.count()) await expectInside(topbar);
|
||||
} finally {
|
||||
if (collectionId !== undefined) {
|
||||
expect((await page.request.delete(`/api/addons/collections/${collectionId}`)).ok()).toBeTruthy();
|
||||
}
|
||||
expect(
|
||||
(await page.request.put('/api/admin/addons/collections', { data: { enabled: !!original.enabled } })).ok()
|
||||
).toBeTruthy();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -32,8 +32,8 @@ async function swipe(page: Page, from: { x: number; y: number }, to: { x: number
|
||||
}
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
|
||||
await cdp.detach()
|
||||
// The strip snaps natively; give the snap and the selection that follows it time to land.
|
||||
await page.waitForTimeout(900)
|
||||
// The out leg is 150ms and the in leg 220ms.
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
const activeChip = (page: Page) => page.locator('button[aria-current="true"]')
|
||||
@@ -72,23 +72,19 @@ test('#2051 phone: the day details area swipes between days', async ({ page }) =
|
||||
|
||||
const day1 = (await activeChip(page).textContent())!.trim()
|
||||
|
||||
// The days are a scroll-snap pager: like a native pager it turns once the
|
||||
// finger has carried the page past its middle, so the swipe covers most of it.
|
||||
const reach = Math.round(env.width * 0.4)
|
||||
const chipText = async () => (await activeChip(page).textContent())!.trim()
|
||||
|
||||
// 1. A swipe left steps forward.
|
||||
await swipe(page, { x: mid + reach, y }, { x: mid - reach, y })
|
||||
await expect.poll(chipText, { message: 'swiping left moved to the next day' }).not.toBe(day1)
|
||||
await swipe(page, { x: mid + 90, y }, { x: mid - 90, y })
|
||||
const day2 = (await activeChip(page).textContent())!.trim()
|
||||
expect(day2, 'swiping left moved to the next day').not.toBe(day1)
|
||||
|
||||
// 2. A swipe right steps back to where it started.
|
||||
await swipe(page, { x: mid - reach, y }, { x: mid + reach, y })
|
||||
await expect.poll(chipText).toBe(day1)
|
||||
await swipe(page, { x: mid - 90, y }, { x: mid + 90, y })
|
||||
expect((await activeChip(page).textContent())!.trim()).toBe(day1)
|
||||
|
||||
// 3. The first day has nowhere to go back to — it must stay put, and the
|
||||
// browser must not have been handed a page-back either.
|
||||
await swipe(page, { x: mid - reach, y }, { x: mid + reach, y })
|
||||
expect(await chipText()).toBe(day1)
|
||||
await swipe(page, { x: mid - 90, y }, { x: mid + 90, y })
|
||||
expect((await activeChip(page).textContent())!.trim()).toBe(day1)
|
||||
expect(page.url()).toContain(`/trips/${tripId}`)
|
||||
|
||||
// 4. A vertical drag belongs to the card's own scroller, never to the day.
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
# iOS release signing and TestFlight upload, driven by .github/workflows/native-release.yml.
|
||||
#
|
||||
# Automatic signing cannot work on a CI runner with no registered devices: it signs
|
||||
# the archive for development and then needs a development provisioning profile, which
|
||||
# requires a device. So this signs manually for distribution instead. The distribution
|
||||
# certificate and the App Store provisioning profile are created (or reused) through the
|
||||
# App Store Connect API key, which means no certificate or profile has to be stored as a
|
||||
# secret: the key the workflow already holds is enough.
|
||||
#
|
||||
# The lane expects these in the environment (the workflow sets them):
|
||||
# ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH — the App Store Connect API key
|
||||
# APPLE_TEAM_ID — the developer team
|
||||
# MARKETING_VERSION, CURRENT_PROJECT_VERSION — the version and build number
|
||||
# RUNNER_TEMP — a writable scratch directory
|
||||
|
||||
default_platform(:ios)
|
||||
|
||||
APP_IDENTIFIER = "com.liketrek.trek"
|
||||
PROJECT = "ios/App/App.xcodeproj"
|
||||
|
||||
# Every signed target with its bundle identifier: the app and its extensions
|
||||
# (ios/scripts/add-extension.rb put them in the project). Each gets its own App
|
||||
# Store profile, created or reused through the API key like the app's.
|
||||
SIGNED_TARGETS = {
|
||||
"App" => APP_IDENTIFIER,
|
||||
"NotificationService" => "com.liketrek.trek.notifications",
|
||||
"TREKWidgets" => "com.liketrek.trek.widgets",
|
||||
"ShareExtension" => "com.liketrek.trek.share",
|
||||
"TREKWatch" => "com.liketrek.trek.watchkitapp",
|
||||
"TREKWatchWidgets" => "com.liketrek.trek.watchkitapp.widgets",
|
||||
}
|
||||
|
||||
platform :ios do
|
||||
desc "Build the signed Release app and upload it to TestFlight (internal testing)."
|
||||
lane :beta do
|
||||
key = app_store_connect_api_key(
|
||||
key_id: ENV.fetch("ASC_KEY_ID"),
|
||||
issuer_id: ENV.fetch("ASC_ISSUER_ID"),
|
||||
key_filepath: ENV.fetch("ASC_KEY_PATH"),
|
||||
in_house: false,
|
||||
)
|
||||
|
||||
# A throwaway keychain with a known password, so fastlane can unlock the imported
|
||||
# private key for code signing (without the password it cannot set the key's access
|
||||
# list and the certificate is never usable).
|
||||
keychain_name = "trek_ci.keychain"
|
||||
keychain_password = "trek-ci-#{ENV.fetch('GITHUB_RUN_ID', 'local')}"
|
||||
create_keychain(
|
||||
name: keychain_name,
|
||||
password: keychain_password,
|
||||
default_keychain: true,
|
||||
unlock: true,
|
||||
timeout: 3600,
|
||||
lock_when_sleeps: false,
|
||||
)
|
||||
keychain_path = File.expand_path("~/Library/Keychains/#{keychain_name}-db")
|
||||
|
||||
# Import the one distribution certificate from its stored .p12, rather than
|
||||
# minting a new one each run — Apple caps distribution certificates at two, so
|
||||
# a fresh one per build would quickly hit the limit. The matching App Store
|
||||
# profile is created (or reused) through the API key.
|
||||
import_certificate(
|
||||
certificate_path: ENV.fetch("CERT_P12_PATH"),
|
||||
certificate_password: ENV.fetch("CERT_P12_PASSWORD"),
|
||||
keychain_name: keychain_name,
|
||||
keychain_path: keychain_path,
|
||||
keychain_password: keychain_password,
|
||||
)
|
||||
profiles = {}
|
||||
SIGNED_TARGETS.each do |target, identifier|
|
||||
get_provisioning_profile(
|
||||
api_key: key,
|
||||
app_identifier: identifier,
|
||||
development: false,
|
||||
)
|
||||
profiles[identifier] = lane_context[SharedValues::SIGH_NAME]
|
||||
|
||||
# Pin each target to its profile for this build; automatic signing stays the
|
||||
# default a developer sees when they open the project in Xcode.
|
||||
update_code_signing_settings(
|
||||
path: PROJECT,
|
||||
use_automatic_signing: false,
|
||||
team_id: ENV.fetch("APPLE_TEAM_ID"),
|
||||
code_sign_identity: "Apple Distribution",
|
||||
bundle_identifier: identifier,
|
||||
profile_name: profiles[identifier],
|
||||
targets: [target],
|
||||
)
|
||||
end
|
||||
|
||||
build_app(
|
||||
project: PROJECT,
|
||||
scheme: "App",
|
||||
configuration: "Release",
|
||||
export_method: "app-store",
|
||||
output_directory: File.join(ENV.fetch("RUNNER_TEMP"), "export"),
|
||||
xcargs: [
|
||||
"MARKETING_VERSION=#{ENV.fetch('MARKETING_VERSION')}",
|
||||
"CURRENT_PROJECT_VERSION=#{ENV.fetch('CURRENT_PROJECT_VERSION')}",
|
||||
].join(" "),
|
||||
export_options: {
|
||||
signingStyle: "manual",
|
||||
provisioningProfiles: profiles,
|
||||
},
|
||||
)
|
||||
|
||||
# Upload to App Store Connect; it shows up in TestFlight after Apple finishes processing.
|
||||
# Internal testing needs no App Review, so nothing here submits the app for review.
|
||||
# "What to Test" comes from the release tag's message (native-release.yml). Apple only
|
||||
# takes it once the build is processed, so with notes the lane waits for that.
|
||||
notes = ENV.fetch("TESTFLIGHT_NOTES", "").strip
|
||||
upload_to_testflight(
|
||||
api_key: key,
|
||||
changelog: notes.empty? ? nil : notes,
|
||||
skip_waiting_for_build_processing: notes.empty?,
|
||||
skip_submission: true,
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -2,11 +2,5 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>production</string>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.liketrek.trek.shared</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,43 +1,16 @@
|
||||
import UIKit
|
||||
import Capacitor
|
||||
import UserNotifications
|
||||
|
||||
@UIApplicationMain
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
var window: UIWindow?
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
// Taps on a TREK notification, and banners while the app is open.
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
// Early, so ticks the watch sent while the app was closed arrive with this launch.
|
||||
TrekWatchLink.shared.activate()
|
||||
// Override point for customization after application launch.
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Push (TrekPush in TrekShellPlugin.swift)
|
||||
|
||||
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||
TrekPush.shared.didRegister(token: deviceToken)
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
||||
TrekPush.shared.didFail(error)
|
||||
}
|
||||
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification,
|
||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
||||
completionHandler([.banner, .list, .sound])
|
||||
}
|
||||
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse,
|
||||
withCompletionHandler completionHandler: @escaping () -> Void) {
|
||||
if let url = response.notification.request.content.userInfo["url"] as? String {
|
||||
TrekPush.shared.opened(url: url)
|
||||
}
|
||||
completionHandler()
|
||||
}
|
||||
|
||||
func applicationWillResignActive(_ application: UIApplication) {
|
||||
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
|
||||
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
|
||||
@@ -60,17 +33,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
||||
}
|
||||
|
||||
// MARK: - Hardware keyboard
|
||||
|
||||
/// The UI's shortcuts for the screen on show (TrekKeyCommands), in the app's
|
||||
/// menu: the overlay a held Command key brings up lists them there.
|
||||
override func buildMenu(with builder: UIMenuBuilder) {
|
||||
super.buildMenu(with: builder)
|
||||
guard builder.system == .main, !TrekKeyCommands.current.isEmpty else { return }
|
||||
let parent: UIMenu.Identifier = builder.menu(for: .application) != nil ? .application : .root
|
||||
builder.insertChild(TrekKeyCommands.menu(), atStartOfMenu: parent)
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication,
|
||||
configurationForConnecting connectingSceneSession: UISceneSession,
|
||||
options: UIScene.ConnectionOptions) -> UISceneConfiguration {
|
||||
|
||||
@@ -71,10 +71,6 @@
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>TREK saves photos and exports you download from your trips back to your photo library.</string>
|
||||
<!-- navigator.geolocation in the WebView ("locate me" on the map). -->
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>TREK uses Face ID to unlock the app when you turn on the app lock.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>TREK uses your location to centre the map on you and to find places near you.</string>
|
||||
<!-- Deep links (@capacitor/app appUrlOpen), matching the intent-filter in
|
||||
@@ -86,27 +82,6 @@
|
||||
apple-app-site-association, and a self-hosted app has no single domain to
|
||||
verify against. SceneDelegate already forwards openURLContexts to
|
||||
Capacitor, so no Swift change is needed. -->
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>Booking document</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Viewer</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Alternate</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>com.adobe.pdf</string>
|
||||
<string>com.apple.ical.ics</string>
|
||||
<string>com.apple.pkpass</string>
|
||||
<string>com.apple.mail.email</string>
|
||||
<string>public.email-message</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<false/>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
@@ -160,8 +135,6 @@
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
</dict>
|
||||
|
||||