Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02b0454b3d | |||
| 9c1e63e64b | |||
| dacc413c34 | |||
| b8f6a99895 | |||
| db82e04b49 | |||
| 3abc9eacf4 | |||
| 3a0edafdf0 | |||
| 35d2b19f4c | |||
| 6b5b2f614d | |||
| 92f10531d0 | |||
| d5ba4439f2 | |||
| 78a28e2747 | |||
| 71e3ddabed | |||
| e59af6dcb3 | |||
| 9755330113 | |||
| 5ca44453ae | |||
| 4461899910 | |||
| ec22642f38 | |||
| 46f4cab1b7 | |||
| c1c71a12cf | |||
| 99a8701bd4 | |||
| 924836be31 | |||
| da05ba1121 | |||
| 1e4f1922e8 | |||
| 3beb5c4223 | |||
| d41a4f0827 | |||
| 41c713fd9a | |||
| e1a08e82b9 | |||
| 0557c7290b | |||
| a2aeeb32c8 | |||
| 640998a4ef | |||
| ac7dde7d93 | |||
| 6e1c9e8e9f | |||
| 354e30a075 | |||
| 37a4e84360 | |||
| 794da3be72 | |||
| 7b8bd4b166 |
@@ -17,6 +17,7 @@ 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'
|
||||
@@ -103,7 +104,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
|
||||
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
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -120,6 +121,8 @@ 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: |
|
||||
@@ -127,7 +130,6 @@ jobs:
|
||||
-project App.xcodeproj \
|
||||
-scheme App \
|
||||
-configuration Debug \
|
||||
-sdk iphonesimulator \
|
||||
-destination 'generic/platform=iOS Simulator' \
|
||||
CODE_SIGNING_ALLOWED=NO
|
||||
|
||||
@@ -159,3 +161,82 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -127,6 +127,9 @@ 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,6 +159,10 @@ 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,3 +106,6 @@ 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,4 +1,6 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'org.jetbrains.kotlin.android'
|
||||
apply plugin: 'org.jetbrains.kotlin.plugin.compose'
|
||||
|
||||
android {
|
||||
namespace = "com.liketrek.trek"
|
||||
@@ -32,6 +34,25 @@ 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')) {
|
||||
@@ -57,14 +78,39 @@ dependencies {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,125 @@
|
||||
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,31 +9,33 @@
|
||||
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|smallestScreenSize|screenLayout|uiMode|navigation|density"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|smallestScreenSize|screenLayout|uiMode|navigation|density|fontScale|touchscreen|colorMode|fontWeightAdjustment"
|
||||
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>
|
||||
<!-- "Open in TREK" / share a booking document into the import. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="application/pdf" />
|
||||
<data android:mimeType="text/calendar" />
|
||||
<data android:mimeType="application/vnd.apple.pkpass" />
|
||||
<data android:mimeType="message/rfc822" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Deep links.
|
||||
|
||||
The scheme is @string/custom_url_scheme (com.liketrek.trek).
|
||||
@@ -60,6 +62,122 @@
|
||||
|
||||
</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"
|
||||
@@ -97,5 +215,10 @@
|
||||
<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,14 +2,25 @@ 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
|
||||
@@ -27,6 +38,26 @@ public class MainActivity extends BridgeActivity {
|
||||
// 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
|
||||
|
||||
@@ -37,6 +37,7 @@ public class TrekPushService extends FirebaseMessagingService {
|
||||
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) {
|
||||
@@ -46,30 +47,74 @@ public class TrekPushService extends FirebaseMessagingService {
|
||||
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);
|
||||
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 || manager.getNotificationChannel(CHANNEL) != null) return;
|
||||
NotificationChannel channel = new NotificationChannel(
|
||||
CHANNEL,
|
||||
context.getString(R.string.push_channel_name),
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
);
|
||||
manager.createNotificationChannel(channel);
|
||||
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);
|
||||
}
|
||||
|
||||
private static void show(Context context, String title, String body, String url, String tag) {
|
||||
@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);
|
||||
@@ -81,12 +126,17 @@ public class TrekPushService extends FirebaseMessagingService {
|
||||
open,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
|
||||
);
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL)
|
||||
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) {
|
||||
@@ -94,6 +144,15 @@ public class TrekPushService extends FirebaseMessagingService {
|
||||
}
|
||||
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.
|
||||
}
|
||||
|
||||
@@ -39,10 +39,19 @@ final class TrekSharedFiles {
|
||||
|
||||
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;
|
||||
if (Intent.ACTION_VIEW.equals(intent.getAction())) uri = intent.getData();
|
||||
// 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);
|
||||
if (uri == null) return;
|
||||
// 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();
|
||||
@@ -64,6 +73,20 @@ final class TrekSharedFiles {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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);
|
||||
|
||||
@@ -22,7 +22,10 @@ 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") }
|
||||
permissions = {
|
||||
@Permission(strings = { Manifest.permission.POST_NOTIFICATIONS }, alias = "notifications"),
|
||||
@Permission(strings = { Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION }, alias = "location"),
|
||||
}
|
||||
)
|
||||
public class TrekShellPlugin extends Plugin {
|
||||
|
||||
@@ -41,7 +44,22 @@ public class TrekShellPlugin extends Plugin {
|
||||
*/
|
||||
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" };
|
||||
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/";
|
||||
@@ -76,6 +94,26 @@ public class TrekShellPlugin extends Plugin {
|
||||
@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);
|
||||
@@ -254,6 +292,8 @@ 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());
|
||||
}
|
||||
@@ -306,6 +346,7 @@ 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(() -> {
|
||||
@@ -348,4 +389,245 @@ 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())));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,16 @@ 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()) {
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
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 }
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
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" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
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() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,815 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 69 KiB |
@@ -0,0 +1,9 @@
|
||||
<?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>
|
||||
@@ -0,0 +1 @@
|
||||
unqualifiedResLocale=en-US
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?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,10 +1,8 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<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>
|
||||
<string name="push_channel_name">Notifications</string>
|
||||
<string name="push_fallback_title">TREK</string>
|
||||
<string name="push_fallback_body">New notification</string>
|
||||
<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>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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,6 +16,19 @@
|
||||
</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.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?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" />
|
||||
@@ -0,0 +1,19 @@
|
||||
<?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" />
|
||||
@@ -0,0 +1,19 @@
|
||||
<?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" />
|
||||
@@ -0,0 +1,19 @@
|
||||
<?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" />
|
||||
@@ -0,0 +1,19 @@
|
||||
<?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" />
|
||||
@@ -0,0 +1,19 @@
|
||||
<?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" />
|
||||
@@ -0,0 +1,19 @@
|
||||
<?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" />
|
||||
@@ -0,0 +1,161 @@
|
||||
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."))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
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,13 +1,17 @@
|
||||
// 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,4 +15,9 @@ 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'
|
||||
}
|
||||
@@ -25,6 +25,9 @@ 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
|
||||
|
||||
@@ -8,12 +8,24 @@
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
01FD437981FC32C2C2C5BDCE /* TREKWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 702D0DF4A7E323CEA94CA03F /* TREKWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
0520A7C30D8D3FACCBB784BE /* ShareItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB2A15008B5664C48B639F94 /* ShareItem.swift */; };
|
||||
0C2D60A4950D7293E1F282CB /* TripDayAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 337775CD9091673D0ADDF325 /* TripDayAttributes.swift */; };
|
||||
0D801738BB09A41A62E9B57F /* TrekStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */; };
|
||||
1091DAAC693DAE3E1CBB7D69 /* WatchStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8850ACF43C79E9D879ACC44 /* WatchStore.swift */; };
|
||||
11B5B80E66AB03C92B0EADBD /* TREKWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96CE3377B458EDD77B809F61 /* TREKWatchApp.swift */; };
|
||||
157A7B737E3FFB610A218D47 /* AtlasContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D004544572B0E85DE29A7B8 /* AtlasContent.swift */; };
|
||||
1A0A6E71365C0A1178B85CB8 /* TrekStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */; };
|
||||
1F8D58E1B4FD9BB84172527A /* WatchSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10FB55D221F4BA18795F33B5 /* WatchSample.swift */; };
|
||||
2162CD349AFFDF070C16A58A /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6E901288FC198E4D33BE754F /* SwiftUI.framework */; };
|
||||
228EA0E0270314581194EC3A /* TREKWatchWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4D88ABD8284E0A39E7586BE /* TREKWatchWidgets.swift */; };
|
||||
239FC886F02275957A1D449A /* FacesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 787A9D6B7D9EB7FED319C1B5 /* FacesView.swift */; };
|
||||
2CFE220A73ACB585F3D4CB8F /* UserNotifications.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2EE43D236E661AA975612A4B /* UserNotifications.framework */; };
|
||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
|
||||
2FB9B214E9DA5C7A25BE0EA1 /* TrekStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */; };
|
||||
318BBC653A438E6D1D493F50 /* DemoMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9B6357E3FBBA4D9CDD1C7A3 /* DemoMode.swift */; };
|
||||
32797E66C09CF7579AC323FD /* TREKWidgetsBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C2E47D0B005674C2B4ADA56 /* TREKWidgetsBundle.swift */; };
|
||||
3FB1872A25267A8E88FCBE3C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BC64330AC19625949E8DC05A /* WidgetKit.framework */; };
|
||||
45B990B86488D5CC2B5222B5 /* WorldGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0AC751335E0136865E08990 /* WorldGrid.swift */; };
|
||||
492FA24FBB57059B0B5007CE /* TripDayAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 337775CD9091673D0ADDF325 /* TripDayAttributes.swift */; };
|
||||
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; };
|
||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
|
||||
@@ -22,31 +34,61 @@
|
||||
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
|
||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
||||
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
||||
512499AF65E091B2BBC9A7BB /* UniformTypeIdentifiers.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E45050B942C96E1ACCAB7CE9 /* UniformTypeIdentifiers.framework */; };
|
||||
5480CCC12F42E9605044AB6B /* ShareHandoff.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56050CCA8DE4BEB4680D9E0B /* ShareHandoff.swift */; };
|
||||
5A4D344328829E0F0B4D2B94 /* BookingsContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6D2F4930E5F152C8090C4F8 /* BookingsContent.swift */; };
|
||||
5BBE9EE02A47EC67C31F5E21 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D0A4B2C6C93971E884561CFA /* Localizable.strings */; };
|
||||
612BB9680DC27BB8BF553060 /* TrekFamily.swift in Sources */ = {isa = PBXBuildFile; fileRef = A030A06726542A2D7F61C54B /* TrekFamily.swift */; };
|
||||
634F4E63DC7C2A0027F0D118 /* Details.swift in Sources */ = {isa = PBXBuildFile; fileRef = F406A4A31BD875A880A7CA14 /* Details.swift */; };
|
||||
661AEED37F1F278677F4F306 /* WatchStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B991C938B24A275550C15977 /* WatchStyle.swift */; };
|
||||
6680B5C572AA37E6E3DFC3A2 /* ComplicationViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9F0323803613DBCB2ADEF2F /* ComplicationViews.swift */; };
|
||||
6EB76B1C6AB4AC78FF021994 /* WatchStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8850ACF43C79E9D879ACC44 /* WatchStore.swift */; };
|
||||
7060F9DB820849A45ECE0507 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3C3D5E2482AC7D20D2054623 /* Assets.xcassets */; };
|
||||
7A1E00032C0000000000A003 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7A1E00022C0000000000A002 /* PrivacyInfo.xcprivacy */; };
|
||||
7A1E00052C0000000000A005 /* TrekBridgeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E00042C0000000000A004 /* TrekBridgeViewController.swift */; };
|
||||
7A1E00072C0000000000A007 /* TrekShellPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E00062C0000000000A006 /* TrekShellPlugin.swift */; };
|
||||
7FB730DC35C234816EAE65AF /* WidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */; };
|
||||
D3C699E2258FBA6A11C1DDE4 /* TripIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8442B4B8988AF5D1BFA3ADE3 /* TripIntent.swift */; };
|
||||
157A7B737E3FFB610A218D47 /* AtlasContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D004544572B0E85DE29A7B8 /* AtlasContent.swift */; };
|
||||
FEB91385917AC44D5CDAA59C /* PackingContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04C0771C8582246A26E0C98E /* PackingContent.swift */; };
|
||||
EA5EDDD4E5F9BC9BC46C563F /* CollabContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D5041DDDAFF5B491B053999 /* CollabContent.swift */; };
|
||||
45B990B86488D5CC2B5222B5 /* WorldGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0AC751335E0136865E08990 /* WorldGrid.swift */; };
|
||||
1A0A6E71365C0A1178B85CB8 /* TrekStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */; };
|
||||
612BB9680DC27BB8BF553060 /* TrekFamily.swift in Sources */ = {isa = PBXBuildFile; fileRef = A030A06726542A2D7F61C54B /* TrekFamily.swift */; };
|
||||
8FC5873BE6A8CB2AFB31A46A /* TripContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */; };
|
||||
9A175689CFCC630BE9BB9EE1 /* TodayContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24B175DE91E1E6E850DB0285 /* TodayContent.swift */; };
|
||||
5A4D344328829E0F0B4D2B94 /* BookingsContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6D2F4930E5F152C8090C4F8 /* BookingsContent.swift */; };
|
||||
F1E29B18C5B1E75A5BC01B57 /* TodosContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEF7DCAA1AEE67F1291668D1 /* TodosContent.swift */; };
|
||||
81E4582AD7514CA1AF373E48 /* WatchSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842D54F96D80A1D1C4C21457 /* WatchSnapshot.swift */; };
|
||||
84EB79E4B677229481809933 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A03A384FE54FAD1ED68E12DC /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
8925D4B7322D6930F3C02959 /* TrekWatchLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */; };
|
||||
896952F864B35C647FF6910A /* WatchSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10FB55D221F4BA18795F33B5 /* WatchSample.swift */; };
|
||||
8FC5873BE6A8CB2AFB31A46A /* TripContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */; };
|
||||
8FFB2B488E43AD339D0BB450 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 3A6054202DCC03D83C2C9602 /* Localizable.strings */; };
|
||||
91B14997828BEF884E1239B4 /* Pages.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB5322D218E340690F499CA2 /* Pages.swift */; };
|
||||
9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */; };
|
||||
96DBDCE4766AC9F13F100A06 /* SharePage.js in Resources */ = {isa = PBXBuildFile; fileRef = 9DC696E1A1FD3A1A1695F9EA /* SharePage.js */; };
|
||||
9A175689CFCC630BE9BB9EE1 /* TodayContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24B175DE91E1E6E850DB0285 /* TodayContent.swift */; };
|
||||
A0E8821CA7CF51AF2D6D8422 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 248A72D1C511B4969D89333A /* Localizable.strings */; };
|
||||
A2B048E183B19BB17E264FDE /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED561D7B7219D23A99FAEB11 /* ShareViewController.swift */; };
|
||||
A8FDD4DBE3F078BF935CC4B9 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFA8802204810ED580DE46F3 /* NotificationService.swift */; };
|
||||
AAE5F2A3903F1E241C395BB8 /* WatchModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42EBD981456D3493C3947441 /* WatchModel.swift */; };
|
||||
AEE032DFFB63C7BEB8AFABBF /* TREKWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = CF4BA0BDDABFBDB24394025D /* TREKWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
B4C0FC386F71D849E141C91E /* NextTripWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7064A9DA92FCC8ACAA4F22A /* NextTripWidget.swift */; };
|
||||
B65B7CFC62EEFF443491EA64 /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 192E69C7E66646BC39566925 /* ActivityKit.framework */; };
|
||||
B8D1816D155C8D52DFCAE25B /* ShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6831E849332626BE8D54CEF /* ShareView.swift */; };
|
||||
BE2DBB926DB04D6CEEFD8B43 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 694631A1018DCE5678F29930 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
C109B48C5A072AF9BE685BE4 /* ComplicationViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9F0323803613DBCB2ADEF2F /* ComplicationViews.swift */; };
|
||||
C3959ACB55B495F7A86F89A9 /* WatchStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B991C938B24A275550C15977 /* WatchStyle.swift */; };
|
||||
D0983EB4FB8015F96E232919 /* WatchSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842D54F96D80A1D1C4C21457 /* WatchSnapshot.swift */; };
|
||||
D3C699E2258FBA6A11C1DDE4 /* TripIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8442B4B8988AF5D1BFA3ADE3 /* TripIntent.swift */; };
|
||||
DE14C398EAFA594CB32ABBF2 /* WidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */; };
|
||||
DF633789F2F667C4CF264C9E /* TripDayLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3259F6C40216CECEBAC6F307 /* TripDayLiveActivity.swift */; };
|
||||
A0E8821CA7CF51AF2D6D8422 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 248A72D1C511B4969D89333A /* Localizable.strings */; };
|
||||
EA5EDDD4E5F9BC9BC46C563F /* CollabContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D5041DDDAFF5B491B053999 /* CollabContent.swift */; };
|
||||
F1E29B18C5B1E75A5BC01B57 /* TodosContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEF7DCAA1AEE67F1291668D1 /* TodosContent.swift */; };
|
||||
F30AA6CFFE04035E7D6AC546 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 3A6054202DCC03D83C2C9602 /* Localizable.strings */; };
|
||||
F9947489F5B9323E519D3903 /* WidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */; };
|
||||
FEB91385917AC44D5CDAA59C /* PackingContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04C0771C8582246A26E0C98E /* PackingContent.swift */; };
|
||||
FECB6034B17C07C879908CEF /* TREKWatchWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0DA9D9714D1E594A44A3569F /* TREKWatchWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
201AA9320B6C5DCBCC3429BF /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 59A840D13C3074A09FCCC7CB;
|
||||
remoteInfo = TREKWatch;
|
||||
};
|
||||
3BB8A38B2B2C8614B24CF2AD /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
|
||||
@@ -54,6 +96,13 @@
|
||||
remoteGlobalIDString = 9480DFBC2C2A112973DA468A;
|
||||
remoteInfo = NotificationService;
|
||||
};
|
||||
64B91219C04CABCE204C7CCC /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 01A63CD6CFBC1463CAA8FFF0;
|
||||
remoteInfo = ShareExtension;
|
||||
};
|
||||
B8BBE7F7A64678D780A7F3D1 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
|
||||
@@ -61,9 +110,38 @@
|
||||
remoteGlobalIDString = A3BE5653156025C97CA62C68;
|
||||
remoteInfo = TREKWidgets;
|
||||
};
|
||||
EA68232794738AB11E866FA3 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 5B5EA8FB8F58C6BFA3B77661;
|
||||
remoteInfo = TREKWatchWidgets;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
9545786868FF757328C69E1E /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
FECB6034B17C07C879908CEF /* TREKWatchWidgets.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
986D3C6100A1AB6BE603CF1C /* Embed Watch Content */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
|
||||
dstSubfolderSpec = 16;
|
||||
files = (
|
||||
AEE032DFFB63C7BEB8AFABBF /* TREKWatch.app in Embed Watch Content */,
|
||||
);
|
||||
name = "Embed Watch Content";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A8F12B42B22FCA041C6AA0DA /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -72,6 +150,7 @@
|
||||
files = (
|
||||
84EB79E4B677229481809933 /* NotificationService.appex in Embed Foundation Extensions */,
|
||||
01FD437981FC32C2C2C5BDCE /* TREKWidgets.appex in Embed Foundation Extensions */,
|
||||
BE2DBB926DB04D6CEEFD8B43 /* ShareExtension.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -79,13 +158,52 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
00B7BB68930D45D35C9AD54A /* az */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = az; path = az.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
037DA05706AE051CEFCA7D75 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
04C0771C8582246A26E0C98E /* PackingContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PackingContent.swift; sourceTree = "<group>"; };
|
||||
07C00C53BA5641B009E40EF1 /* zh-Hans */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||
07DF720DA5E63F842526A075 /* vi */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
090D74588DA8436DEC275567 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
095A4E5D36F6E22070842EC1 /* nl */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
0A7C4491A7ABDDBEBE00E5BF /* sk */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
0BA66BF6D1F4FB57EFF31981 /* ShareExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; };
|
||||
0CC4BCF06E7719280549FFF1 /* el */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
0DA9D9714D1E594A44A3569F /* TREKWatchWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TREKWatchWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
10FB55D221F4BA18795F33B5 /* WatchSample.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchSample.swift; sourceTree = "<group>"; };
|
||||
114C13FB91B554AE8DC4372B /* et */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekWatchLink.swift; sourceTree = "<group>"; };
|
||||
12DE24E0B7E2E5F3564D952B /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
1335F1EEB49D744B27A00F31 /* th */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
15884CA899CAD3FFD98BA927 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekStyle.swift; sourceTree = "<group>"; };
|
||||
192E69C7E66646BC39566925 /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; };
|
||||
1947CC62E5A6FF3483BEB578 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
1D8F2B5132A2F1B7089E767F /* vi */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
1E7522315D47F086145DC4B8 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
1ED347C91E03E51FDF8ABC1A /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||
22A904A2E55946892F488B1A /* th */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
230D9B2E5898FCEC3EC4FB44 /* pl */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
23AE662FD3762641A2047F72 /* ja */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
241C37D114DDD53C7E915CB8 /* id */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
24B175DE91E1E6E850DB0285 /* TodayContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TodayContent.swift; sourceTree = "<group>"; };
|
||||
2D75538DBBAA6B2366CD8B69 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
2EE43D236E661AA975612A4B /* UserNotifications.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UserNotifications.framework; path = System/Library/Frameworks/UserNotifications.framework; sourceTree = SDKROOT; };
|
||||
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
|
||||
2FD81D4601E9EB8744171B09 /* zh-Hant */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||
3259F6C40216CECEBAC6F307 /* TripDayLiveActivity.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripDayLiveActivity.swift; sourceTree = "<group>"; };
|
||||
32A62EE30F8AD514D7DB2CAA /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
337775CD9091673D0ADDF325 /* TripDayAttributes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripDayAttributes.swift; sourceTree = "<group>"; };
|
||||
37FA9D6C0C247EC699CF326C /* TREKWatchWidgets.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = TREKWatchWidgets.entitlements; sourceTree = "<group>"; };
|
||||
38C632E7EE9C0DF1CA0BF0A0 /* cs */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
3C3D5E2482AC7D20D2054623 /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
3CB565FC61AB5AEA87A3F615 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
3DF4F4305878F949DD09193F /* NotificationService.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = NotificationService.entitlements; sourceTree = "<group>"; };
|
||||
405B04DFF3A978D1046147C6 /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
408F0E543D49BFCCF6B19194 /* es */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
4167040E2C29E44D55580916 /* ru */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
42EBD981456D3493C3947441 /* WatchModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchModel.swift; sourceTree = "<group>"; };
|
||||
467929A8EB65D22F8FA91588 /* ca */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
4D004544572B0E85DE29A7B8 /* AtlasContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AtlasContent.swift; sourceTree = "<group>"; };
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
|
||||
504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
@@ -94,61 +212,107 @@
|
||||
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||
56050CCA8DE4BEB4680D9E0B /* ShareHandoff.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareHandoff.swift; sourceTree = "<group>"; };
|
||||
57DF93CD1B88A7135481BF03 /* de */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
5C0109EA628FA897B7BFDD3E /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
608F00CBD96957EEA888E50A /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||
60EB00F3670B41B898765DF9 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
611A99D899DC374A5B7E1560 /* tr */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
63D3A4166431A36A5C30F942 /* az */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = az; path = az.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
66E7B341F942576F4BF6E4BA /* zh-Hans */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||
683725459244601B71D92E2D /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
6932A417DC4BC05BF07D80E2 /* it */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
694631A1018DCE5678F29930 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
697E5726770333015DDD444D /* TREKWatch.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = TREKWatch.entitlements; sourceTree = "<group>"; };
|
||||
6E901288FC198E4D33BE754F /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
||||
702D0DF4A7E323CEA94CA03F /* TREKWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TREKWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
7328217E23761C22814FEC25 /* az */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = az; path = az.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
772816394C94096090C2F8FE /* ar */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
77A0112CA76E5C1787073AEB /* hu */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
77C217524DA98A6A92F422F4 /* zh-Hant */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||
787A9D6B7D9EB7FED319C1B5 /* FacesView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FacesView.swift; sourceTree = "<group>"; };
|
||||
7887877A91D6C782111A8388 /* it */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
7A1E00012C0000000000A001 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
|
||||
7A1E00022C0000000000A002 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
7A1E00042C0000000000A004 /* TrekBridgeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrekBridgeViewController.swift; sourceTree = "<group>"; };
|
||||
7A1E00062C0000000000A006 /* TrekShellPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrekShellPlugin.swift; sourceTree = "<group>"; };
|
||||
7D5041DDDAFF5B491B053999 /* CollabContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CollabContent.swift; sourceTree = "<group>"; };
|
||||
7D525040492AFBC121FD4CC6 /* ar */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
842D54F96D80A1D1C4C21457 /* WatchSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchSnapshot.swift; sourceTree = "<group>"; };
|
||||
8442B4B8988AF5D1BFA3ADE3 /* TripIntent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripIntent.swift; sourceTree = "<group>"; };
|
||||
849A85B6008760CCD7D9AE6A /* id */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
84CB5C3B6BD4E1DE1A8BD2B4 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
889DA33D98BC601268861D54 /* fr */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
8A0289CB321AE008D8815A19 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
8C2E47D0B005674C2B4ADA56 /* TREKWidgetsBundle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TREKWidgetsBundle.swift; sourceTree = "<group>"; };
|
||||
8CBB0E5B9BE05CE82C8DA7F4 /* sv */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
8D361F7B14AE9F5ACE3BFE78 /* et */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
91F0DD852C0D6D4578449977 /* en */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
9582B6822FE993A50072D4E8 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||
96CE3377B458EDD77B809F61 /* TREKWatchApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TREKWatchApp.swift; sourceTree = "<group>"; };
|
||||
9A3FAA9DBC67D12389EEF0CF /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
9D673E1909A5B750376ECB41 /* en */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
9DC696E1A1FD3A1A1695F9EA /* SharePage.js */ = {isa = PBXFileReference; includeInIndex = 1; path = SharePage.js; sourceTree = "<group>"; };
|
||||
9F756414EFBB3542F65C14CE /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
A030A06726542A2D7F61C54B /* TrekFamily.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekFamily.swift; sourceTree = "<group>"; };
|
||||
A03A384FE54FAD1ED68E12DC /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
A06BBDA4CF70465F2E3F21D3 /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
A2F3670F49C3D74C3D98E457 /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
A479036A85683EFF2922D096 /* el */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
A5C182FC253CBEE8D5439536 /* ca */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
AA02F5CBC22E08415CC1C9BE /* pl */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
AB5322D218E340690F499CA2 /* Pages.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Pages.swift; sourceTree = "<group>"; };
|
||||
AEF7DCAA1AEE67F1291668D1 /* TodosContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TodosContent.swift; sourceTree = "<group>"; };
|
||||
B0450E66BFCF00CC4097330A /* he */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
B680CBF1268D2EE1982B165C /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
B7F33B7A5C27C126DF29DCA4 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
B8850ACF43C79E9D879ACC44 /* WatchStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchStore.swift; sourceTree = "<group>"; };
|
||||
B991C938B24A275550C15977 /* WatchStyle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchStyle.swift; sourceTree = "<group>"; };
|
||||
B9F0323803613DBCB2ADEF2F /* ComplicationViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComplicationViews.swift; sourceTree = "<group>"; };
|
||||
BB2A15008B5664C48B639F94 /* ShareItem.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareItem.swift; sourceTree = "<group>"; };
|
||||
BBB2E19CD540AB30B44BECCA /* TREKWidgets.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = TREKWidgets.entitlements; sourceTree = "<group>"; };
|
||||
BC64330AC19625949E8DC05A /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
||||
BE41D7212DCB0C78E00A8462 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
BF10C31D56A1C10C6BF62046 /* pt-BR */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||
C24CDF14EBF4CF52CBF3DDD0 /* he */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
C333CDB6CAD048E3F9242222 /* ru */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
C6A6008115A85C9AAE181C5E /* sk */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
C7064A9DA92FCC8ACAA4F22A /* NextTripWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NextTripWidget.swift; sourceTree = "<group>"; };
|
||||
DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetSnapshot.swift; sourceTree = "<group>"; };
|
||||
8442B4B8988AF5D1BFA3ADE3 /* TripIntent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripIntent.swift; sourceTree = "<group>"; };
|
||||
4D004544572B0E85DE29A7B8 /* AtlasContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AtlasContent.swift; sourceTree = "<group>"; };
|
||||
04C0771C8582246A26E0C98E /* PackingContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PackingContent.swift; sourceTree = "<group>"; };
|
||||
7D5041DDDAFF5B491B053999 /* CollabContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CollabContent.swift; sourceTree = "<group>"; };
|
||||
C73847E8A007B21B8AD1DCD4 /* tr */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
C996A3060A2D20A4B5C9C377 /* ko */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
C9B6357E3FBBA4D9CDD1C7A3 /* DemoMode.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DemoMode.swift; sourceTree = "<group>"; };
|
||||
CC077989456FB9DB5F4FF676 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||
CD6763B965A87DDB6FCC2D05 /* de */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
CD83ADB6D661D6F7A64CDAB6 /* es */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
CE79157F1170A47CF7FC6FE0 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
CF4BA0BDDABFBDB24394025D /* TREKWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TREKWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
D0AC751335E0136865E08990 /* WorldGrid.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WorldGrid.swift; sourceTree = "<group>"; };
|
||||
15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekStyle.swift; sourceTree = "<group>"; };
|
||||
A030A06726542A2D7F61C54B /* TrekFamily.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekFamily.swift; sourceTree = "<group>"; };
|
||||
DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripContent.swift; sourceTree = "<group>"; };
|
||||
24B175DE91E1E6E850DB0285 /* TodayContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TodayContent.swift; sourceTree = "<group>"; };
|
||||
E6D2F4930E5F152C8090C4F8 /* BookingsContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookingsContent.swift; sourceTree = "<group>"; };
|
||||
AEF7DCAA1AEE67F1291668D1 /* TodosContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TodosContent.swift; sourceTree = "<group>"; };
|
||||
FFA8802204810ED580DE46F3 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
|
||||
84CB5C3B6BD4E1DE1A8BD2B4 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
EE30A6DF943052C6ADE92D63 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
ECC80E1314B31ED2D2347411 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
7328217E23761C22814FEC25 /* az */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = az; path = az.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
CC077989456FB9DB5F4FF676 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt-BR; path = pt-BR.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
405B04DFF3A978D1046147C6 /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
D84D92C18CC022646692CB9B /* sv */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
D863C8994C9DAA79D236401C /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetSnapshot.swift; sourceTree = "<group>"; };
|
||||
DC0C9AB4D77D77A7BC2BA85B /* ja */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
DD341749E47B7DF89BA9A46D /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
9A3FAA9DBC67D12389EEF0CF /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
8D361F7B14AE9F5ACE3BFE78 /* et */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
60EB00F3670B41B898765DF9 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
B680CBF1268D2EE1982B165C /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
12DE24E0B7E2E5F3564D952B /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
15884CA899CAD3FFD98BA927 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
A2F3670F49C3D74C3D98E457 /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
1947CC62E5A6FF3483BEB578 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
8A0289CB321AE008D8815A19 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
B7F33B7A5C27C126DF29DCA4 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
683725459244601B71D92E2D /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
32A62EE30F8AD514D7DB2CAA /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
037DA05706AE051CEFCA7D75 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
090D74588DA8436DEC275567 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
9F756414EFBB3542F65C14CE /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
5C0109EA628FA897B7BFDD3E /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
DE0046E6067251CB0D61EC7A /* et */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripContent.swift; sourceTree = "<group>"; };
|
||||
DE8B592FE52EC6FFB589751E /* fr */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
E2CFBDBCC474A7A72E053A8E /* uk */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
E45050B942C96E1ACCAB7CE9 /* UniformTypeIdentifiers.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UniformTypeIdentifiers.framework; path = System/Library/Frameworks/UniformTypeIdentifiers.framework; sourceTree = SDKROOT; };
|
||||
E47D5FF1A217D5F77B3C7548 /* uk */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
E4AA747C2B20D0CDA0A9538A /* hu */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
E5800E4ECECED54A47CDABAA /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
A06BBDA4CF70465F2E3F21D3 /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
3CB565FC61AB5AEA87A3F615 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
608F00CBD96957EEA888E50A /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = zh-Hans; path = zh-Hans.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
1ED347C91E03E51FDF8ABC1A /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = zh-Hant; path = zh-Hant.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
E6831E849332626BE8D54CEF /* ShareView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = "<group>"; };
|
||||
E6D2F4930E5F152C8090C4F8 /* BookingsContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookingsContent.swift; sourceTree = "<group>"; };
|
||||
EAFBDD97043B34E4B64FDA86 /* pt-BR */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||
EC8D1292C9FC6430C059960A /* nl */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
ECC80E1314B31ED2D2347411 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
ED561D7B7219D23A99FAEB11 /* ShareViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
|
||||
EE30A6DF943052C6ADE92D63 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
F406A4A31BD875A880A7CA14 /* Details.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Details.swift; sourceTree = "<group>"; };
|
||||
F410423B093EBD2DD11C0FFC /* ko */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
F4D88ABD8284E0A39E7586BE /* TREKWatchWidgets.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TREKWatchWidgets.swift; sourceTree = "<group>"; };
|
||||
FB2D9CFF5AC22AEB8CA3D7BD /* cs */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
FFA8802204810ED580DE46F3 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -170,6 +334,20 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
622430061CE31239E1D64FB8 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
69112BCF6652B92DFA2AE6BB /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
C9AA4ADC0D397C620A35B6A3 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -178,6 +356,14 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
DB289D9657376A832309DE5D /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
512499AF65E091B2BBC9A7BB /* UniformTypeIdentifiers.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
@@ -197,6 +383,10 @@
|
||||
5198C9768EB9762A60701C92 /* Frameworks */,
|
||||
74C1FC6022E562372A71EEC9 /* NotificationService */,
|
||||
FB9226BC82835538BCE3DC0A /* TREKWidgets */,
|
||||
DFD2010504102AC0E682D00F /* ShareExtension */,
|
||||
A5731FDC6B43DF41413C28A6 /* TREKWatch */,
|
||||
83BCA52D4B145E66DC1B0111 /* TREKWatchKit */,
|
||||
F3ECD357918C4A98B023F65F /* TREKWatchWidgets */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -206,6 +396,9 @@
|
||||
504EC3041FED79650016851F /* App.app */,
|
||||
A03A384FE54FAD1ED68E12DC /* NotificationService.appex */,
|
||||
702D0DF4A7E323CEA94CA03F /* TREKWidgets.appex */,
|
||||
694631A1018DCE5678F29930 /* ShareExtension.appex */,
|
||||
CF4BA0BDDABFBDB24394025D /* TREKWatch.app */,
|
||||
0DA9D9714D1E594A44A3569F /* TREKWatchWidgets.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -226,6 +419,7 @@
|
||||
7A1E00022C0000000000A002 /* PrivacyInfo.xcprivacy */,
|
||||
2FAD9762203C412B000D30F8 /* config.xml */,
|
||||
50B271D01FEDC1A000F3C39B /* public */,
|
||||
125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */,
|
||||
);
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
@@ -238,6 +432,8 @@
|
||||
BC64330AC19625949E8DC05A /* WidgetKit.framework */,
|
||||
6E901288FC198E4D33BE754F /* SwiftUI.framework */,
|
||||
192E69C7E66646BC39566925 /* ActivityKit.framework */,
|
||||
E45050B942C96E1ACCAB7CE9 /* UniformTypeIdentifiers.framework */,
|
||||
DC1BBE9AA7035B32D3653122 /* watchOS */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
@@ -253,6 +449,71 @@
|
||||
path = NotificationService;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
83BCA52D4B145E66DC1B0111 /* TREKWatchKit */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B9F0323803613DBCB2ADEF2F /* ComplicationViews.swift */,
|
||||
10FB55D221F4BA18795F33B5 /* WatchSample.swift */,
|
||||
842D54F96D80A1D1C4C21457 /* WatchSnapshot.swift */,
|
||||
B8850ACF43C79E9D879ACC44 /* WatchStore.swift */,
|
||||
B991C938B24A275550C15977 /* WatchStyle.swift */,
|
||||
);
|
||||
name = TREKWatchKit;
|
||||
path = TREKWatchKit;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A5731FDC6B43DF41413C28A6 /* TREKWatch */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3C3D5E2482AC7D20D2054623 /* Assets.xcassets */,
|
||||
C9B6357E3FBBA4D9CDD1C7A3 /* DemoMode.swift */,
|
||||
F406A4A31BD875A880A7CA14 /* Details.swift */,
|
||||
787A9D6B7D9EB7FED319C1B5 /* FacesView.swift */,
|
||||
D863C8994C9DAA79D236401C /* Info.plist */,
|
||||
AB5322D218E340690F499CA2 /* Pages.swift */,
|
||||
697E5726770333015DDD444D /* TREKWatch.entitlements */,
|
||||
96CE3377B458EDD77B809F61 /* TREKWatchApp.swift */,
|
||||
42EBD981456D3493C3947441 /* WatchModel.swift */,
|
||||
3A6054202DCC03D83C2C9602 /* Localizable.strings */,
|
||||
);
|
||||
name = TREKWatch;
|
||||
path = TREKWatch;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DC1BBE9AA7035B32D3653122 /* watchOS */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
);
|
||||
name = watchOS;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DFD2010504102AC0E682D00F /* ShareExtension */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CE79157F1170A47CF7FC6FE0 /* Info.plist */,
|
||||
0BA66BF6D1F4FB57EFF31981 /* ShareExtension.entitlements */,
|
||||
56050CCA8DE4BEB4680D9E0B /* ShareHandoff.swift */,
|
||||
BB2A15008B5664C48B639F94 /* ShareItem.swift */,
|
||||
9DC696E1A1FD3A1A1695F9EA /* SharePage.js */,
|
||||
E6831E849332626BE8D54CEF /* ShareView.swift */,
|
||||
ED561D7B7219D23A99FAEB11 /* ShareViewController.swift */,
|
||||
D0A4B2C6C93971E884561CFA /* Localizable.strings */,
|
||||
);
|
||||
name = ShareExtension;
|
||||
path = ShareExtension;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F3ECD357918C4A98B023F65F /* TREKWatchWidgets */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2D75538DBBAA6B2366CD8B69 /* Info.plist */,
|
||||
37FA9D6C0C247EC699CF326C /* TREKWatchWidgets.entitlements */,
|
||||
F4D88ABD8284E0A39E7586BE /* TREKWatchWidgets.swift */,
|
||||
);
|
||||
name = TREKWatchWidgets;
|
||||
path = TREKWatchWidgets;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FB9226BC82835538BCE3DC0A /* TREKWidgets */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -283,6 +544,23 @@
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
01A63CD6CFBC1463CAA8FFF0 /* ShareExtension */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 1369CF6148320C447EBF3B4A /* Build configuration list for PBXNativeTarget "ShareExtension" */;
|
||||
buildPhases = (
|
||||
E0B13DA0CF9C1103058FE869 /* Sources */,
|
||||
DB289D9657376A832309DE5D /* Frameworks */,
|
||||
EB430D4F409D7961AF5603C9 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = ShareExtension;
|
||||
productName = ShareExtension;
|
||||
productReference = 694631A1018DCE5678F29930 /* ShareExtension.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
504EC3031FED79650016851F /* App */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */;
|
||||
@@ -291,12 +569,15 @@
|
||||
504EC3011FED79650016851F /* Frameworks */,
|
||||
504EC3021FED79650016851F /* Resources */,
|
||||
A8F12B42B22FCA041C6AA0DA /* Embed Foundation Extensions */,
|
||||
986D3C6100A1AB6BE603CF1C /* Embed Watch Content */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
E23A0A46599B8EE7A5B74F45 /* PBXTargetDependency */,
|
||||
4CD7F5AF02CB806E4189489A /* PBXTargetDependency */,
|
||||
855AE76937B942D62DFFD7E2 /* PBXTargetDependency */,
|
||||
DC6A321F5C3C7D5EF0973677 /* PBXTargetDependency */,
|
||||
);
|
||||
name = App;
|
||||
packageProductDependencies = (
|
||||
@@ -306,6 +587,42 @@
|
||||
productReference = 504EC3041FED79650016851F /* App.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
59A840D13C3074A09FCCC7CB /* TREKWatch */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = DF9244A5B1D6A7560CB92D4E /* Build configuration list for PBXNativeTarget "TREKWatch" */;
|
||||
buildPhases = (
|
||||
B3023E0FD7245E6465981AC5 /* Sources */,
|
||||
622430061CE31239E1D64FB8 /* Frameworks */,
|
||||
E46DCC12600D7B9D1381FBCE /* Resources */,
|
||||
9545786868FF757328C69E1E /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
5D0E9608BF309D8A27F5C4B6 /* PBXTargetDependency */,
|
||||
);
|
||||
name = TREKWatch;
|
||||
productName = TREKWatch;
|
||||
productReference = CF4BA0BDDABFBDB24394025D /* TREKWatch.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
5B5EA8FB8F58C6BFA3B77661 /* TREKWatchWidgets */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = C24DF86369B92FC86F735AEE /* Build configuration list for PBXNativeTarget "TREKWatchWidgets" */;
|
||||
buildPhases = (
|
||||
068381E1E877C97A3390DE57 /* Sources */,
|
||||
69112BCF6652B92DFA2AE6BB /* Frameworks */,
|
||||
F36545081C7270E279639BF4 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = TREKWatchWidgets;
|
||||
productName = TREKWatchWidgets;
|
||||
productReference = 0DA9D9714D1E594A44A3569F /* TREKWatchWidgets.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
9480DFBC2C2A112973DA468A /* NotificationService */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 4E3E5FA93A15425251E22D48 /* Build configuration list for PBXNativeTarget "NotificationService" */;
|
||||
@@ -407,6 +724,9 @@
|
||||
504EC3031FED79650016851F /* App */,
|
||||
9480DFBC2C2A112973DA468A /* NotificationService */,
|
||||
A3BE5653156025C97CA62C68 /* TREKWidgets */,
|
||||
01A63CD6CFBC1463CAA8FFF0 /* ShareExtension */,
|
||||
59A840D13C3074A09FCCC7CB /* TREKWatch */,
|
||||
5B5EA8FB8F58C6BFA3B77661 /* TREKWatchWidgets */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -441,9 +761,50 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
E46DCC12600D7B9D1381FBCE /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
7060F9DB820849A45ECE0507 /* Assets.xcassets in Resources */,
|
||||
8FFB2B488E43AD339D0BB450 /* Localizable.strings in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
EB430D4F409D7961AF5603C9 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
96DBDCE4766AC9F13F100A06 /* SharePage.js in Resources */,
|
||||
5BBE9EE02A47EC67C31F5E21 /* Localizable.strings in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
F36545081C7270E279639BF4 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
F30AA6CFFE04035E7D6AC546 /* Localizable.strings in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
068381E1E877C97A3390DE57 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
228EA0E0270314581194EC3A /* TREKWatchWidgets.swift in Sources */,
|
||||
C109B48C5A072AF9BE685BE4 /* ComplicationViews.swift in Sources */,
|
||||
896952F864B35C647FF6910A /* WatchSample.swift in Sources */,
|
||||
81E4582AD7514CA1AF373E48 /* WatchSnapshot.swift in Sources */,
|
||||
6EB76B1C6AB4AC78FF021994 /* WatchStore.swift in Sources */,
|
||||
661AEED37F1F278677F4F306 /* WatchStyle.swift in Sources */,
|
||||
0D801738BB09A41A62E9B57F /* TrekStyle.swift in Sources */,
|
||||
DE14C398EAFA594CB32ABBF2 /* WidgetSnapshot.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
1EA0B85A2E70AC56F83C09C9 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -461,6 +822,7 @@
|
||||
7A1E00052C0000000000A005 /* TrekBridgeViewController.swift in Sources */,
|
||||
7A1E00072C0000000000A007 /* TrekShellPlugin.swift in Sources */,
|
||||
0C2D60A4950D7293E1F282CB /* TripDayAttributes.swift in Sources */,
|
||||
8925D4B7322D6930F3C02959 /* TrekWatchLink.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -487,6 +849,37 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B3023E0FD7245E6465981AC5 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
318BBC653A438E6D1D493F50 /* DemoMode.swift in Sources */,
|
||||
634F4E63DC7C2A0027F0D118 /* Details.swift in Sources */,
|
||||
239FC886F02275957A1D449A /* FacesView.swift in Sources */,
|
||||
91B14997828BEF884E1239B4 /* Pages.swift in Sources */,
|
||||
11B5B80E66AB03C92B0EADBD /* TREKWatchApp.swift in Sources */,
|
||||
AAE5F2A3903F1E241C395BB8 /* WatchModel.swift in Sources */,
|
||||
6680B5C572AA37E6E3DFC3A2 /* ComplicationViews.swift in Sources */,
|
||||
1F8D58E1B4FD9BB84172527A /* WatchSample.swift in Sources */,
|
||||
D0983EB4FB8015F96E232919 /* WatchSnapshot.swift in Sources */,
|
||||
1091DAAC693DAE3E1CBB7D69 /* WatchStore.swift in Sources */,
|
||||
C3959ACB55B495F7A86F89A9 /* WatchStyle.swift in Sources */,
|
||||
2FB9B214E9DA5C7A25BE0EA1 /* TrekStyle.swift in Sources */,
|
||||
F9947489F5B9323E519D3903 /* WidgetSnapshot.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
E0B13DA0CF9C1103058FE869 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5480CCC12F42E9605044AB6B /* ShareHandoff.swift in Sources */,
|
||||
0520A7C30D8D3FACCBB784BE /* ShareItem.swift in Sources */,
|
||||
B8D1816D155C8D52DFCAE25B /* ShareView.swift in Sources */,
|
||||
A2B048E183B19BB17E264FDE /* ShareViewController.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
@@ -496,6 +889,24 @@
|
||||
target = A3BE5653156025C97CA62C68 /* TREKWidgets */;
|
||||
targetProxy = B8BBE7F7A64678D780A7F3D1 /* PBXContainerItemProxy */;
|
||||
};
|
||||
5D0E9608BF309D8A27F5C4B6 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
name = TREKWatchWidgets;
|
||||
target = 5B5EA8FB8F58C6BFA3B77661 /* TREKWatchWidgets */;
|
||||
targetProxy = EA68232794738AB11E866FA3 /* PBXContainerItemProxy */;
|
||||
};
|
||||
855AE76937B942D62DFFD7E2 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
name = ShareExtension;
|
||||
target = 01A63CD6CFBC1463CAA8FFF0 /* ShareExtension */;
|
||||
targetProxy = 64B91219C04CABCE204C7CCC /* PBXContainerItemProxy */;
|
||||
};
|
||||
DC6A321F5C3C7D5EF0973677 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
name = TREKWatch;
|
||||
target = 59A840D13C3074A09FCCC7CB /* TREKWatch */;
|
||||
targetProxy = 201AA9320B6C5DCBCC3429BF /* PBXContainerItemProxy */;
|
||||
};
|
||||
E23A0A46599B8EE7A5B74F45 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
name = NotificationService;
|
||||
@@ -540,6 +951,41 @@
|
||||
name = Localizable.strings;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
3A6054202DCC03D83C2C9602 /* Localizable.strings */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
772816394C94096090C2F8FE /* ar */,
|
||||
00B7BB68930D45D35C9AD54A /* az */,
|
||||
467929A8EB65D22F8FA91588 /* ca */,
|
||||
FB2D9CFF5AC22AEB8CA3D7BD /* cs */,
|
||||
57DF93CD1B88A7135481BF03 /* de */,
|
||||
0CC4BCF06E7719280549FFF1 /* el */,
|
||||
91F0DD852C0D6D4578449977 /* en */,
|
||||
CD83ADB6D661D6F7A64CDAB6 /* es */,
|
||||
DE0046E6067251CB0D61EC7A /* et */,
|
||||
889DA33D98BC601268861D54 /* fr */,
|
||||
C24CDF14EBF4CF52CBF3DDD0 /* he */,
|
||||
E4AA747C2B20D0CDA0A9538A /* hu */,
|
||||
849A85B6008760CCD7D9AE6A /* id */,
|
||||
7887877A91D6C782111A8388 /* it */,
|
||||
23AE662FD3762641A2047F72 /* ja */,
|
||||
F410423B093EBD2DD11C0FFC /* ko */,
|
||||
EC8D1292C9FC6430C059960A /* nl */,
|
||||
230D9B2E5898FCEC3EC4FB44 /* pl */,
|
||||
BF10C31D56A1C10C6BF62046 /* pt-BR */,
|
||||
C333CDB6CAD048E3F9242222 /* ru */,
|
||||
C6A6008115A85C9AAE181C5E /* sk */,
|
||||
8CBB0E5B9BE05CE82C8DA7F4 /* sv */,
|
||||
1335F1EEB49D744B27A00F31 /* th */,
|
||||
C73847E8A007B21B8AD1DCD4 /* tr */,
|
||||
E47D5FF1A217D5F77B3C7548 /* uk */,
|
||||
07DF720DA5E63F842526A075 /* vi */,
|
||||
07C00C53BA5641B009E40EF1 /* zh-Hans */,
|
||||
2FD81D4601E9EB8744171B09 /* zh-Hant */,
|
||||
);
|
||||
name = Localizable.strings;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
504EC30B1FED79650016851F /* Main.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
@@ -556,9 +1002,96 @@
|
||||
name = LaunchScreen.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D0A4B2C6C93971E884561CFA /* Localizable.strings */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
7D525040492AFBC121FD4CC6 /* ar */,
|
||||
63D3A4166431A36A5C30F942 /* az */,
|
||||
A5C182FC253CBEE8D5439536 /* ca */,
|
||||
38C632E7EE9C0DF1CA0BF0A0 /* cs */,
|
||||
CD6763B965A87DDB6FCC2D05 /* de */,
|
||||
A479036A85683EFF2922D096 /* el */,
|
||||
9D673E1909A5B750376ECB41 /* en */,
|
||||
408F0E543D49BFCCF6B19194 /* es */,
|
||||
114C13FB91B554AE8DC4372B /* et */,
|
||||
DE8B592FE52EC6FFB589751E /* fr */,
|
||||
B0450E66BFCF00CC4097330A /* he */,
|
||||
77A0112CA76E5C1787073AEB /* hu */,
|
||||
241C37D114DDD53C7E915CB8 /* id */,
|
||||
6932A417DC4BC05BF07D80E2 /* it */,
|
||||
DC0C9AB4D77D77A7BC2BA85B /* ja */,
|
||||
C996A3060A2D20A4B5C9C377 /* ko */,
|
||||
095A4E5D36F6E22070842EC1 /* nl */,
|
||||
AA02F5CBC22E08415CC1C9BE /* pl */,
|
||||
EAFBDD97043B34E4B64FDA86 /* pt-BR */,
|
||||
4167040E2C29E44D55580916 /* ru */,
|
||||
0A7C4491A7ABDDBEBE00E5BF /* sk */,
|
||||
D84D92C18CC022646692CB9B /* sv */,
|
||||
22A904A2E55946892F488B1A /* th */,
|
||||
611A99D899DC374A5B7E1560 /* tr */,
|
||||
E2CFBDBCC474A7A72E053A8E /* uk */,
|
||||
1D8F2B5132A2F1B7089E767F /* vi */,
|
||||
66E7B341F942576F4BF6E4BA /* zh-Hans */,
|
||||
77C217524DA98A6A92F422F4 /* zh-Hant */,
|
||||
);
|
||||
name = Localizable.strings;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
1F7D993B412B63E918648BA1 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = TREKWatch/TREKWatch.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = TREKWatch/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.liketrek.trek.watchkitapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 11.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
31CC9E868E52D06AD12E83D6 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.liketrek.trek.share;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
3ECF3537A139DB5CAC23E502 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -739,6 +1272,58 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
5779B79420A5ECC25B0E8ACC /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.liketrek.trek.share;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
57D289EF9468E358528F5FBB /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = TREKWatch/TREKWatch.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = TREKWatch/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.liketrek.trek.watchkitapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 11.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
686FA31AEC6FE5BA3EF95E99 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -790,6 +1375,30 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
C04A1F63D9298FA52E6C727E /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = TREKWatchWidgets/TREKWatchWidgets.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = TREKWatchWidgets/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.liketrek.trek.watchkitapp.widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 11.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
C5621F5D75100AAEBB1BD22A /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -816,9 +1425,43 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
DF4816365A7D101A9EA8A0C7 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = TREKWatchWidgets/TREKWatchWidgets.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = TREKWatchWidgets/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.liketrek.trek.watchkitapp.widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 11.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
1369CF6148320C447EBF3B4A /* Build configuration list for PBXNativeTarget "ShareExtension" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
5779B79420A5ECC25B0E8ACC /* Release */,
|
||||
31CC9E868E52D06AD12E83D6 /* Debug */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
4E3E5FA93A15425251E22D48 /* Build configuration list for PBXNativeTarget "NotificationService" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
@@ -855,6 +1498,24 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
C24DF86369B92FC86F735AEE /* Build configuration list for PBXNativeTarget "TREKWatchWidgets" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
DF4816365A7D101A9EA8A0C7 /* Release */,
|
||||
C04A1F63D9298FA52E6C727E /* Debug */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
DF9244A5B1D6A7560CB92D4E /* Build configuration list for PBXNativeTarget "TREKWatch" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
1F7D993B412B63E918648BA1 /* Release */,
|
||||
57D289EF9468E358528F5FBB /* Debug */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
|
||||
@@ -10,6 +10,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||
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()
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -32,13 +32,147 @@
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<!-- TREK sends no data to third parties and runs no trackers: all traffic
|
||||
goes to the server the user chose. -->
|
||||
<!-- No trackers and no advertising. Trip data goes to the server the user
|
||||
connects; with TREK Cloud that server is ours, and buying TREK Cloud in
|
||||
the app sends the email, the chosen address and the store's purchase to
|
||||
liketrek.com. That is what is declared below, all for the app to work,
|
||||
linked to the account, never for tracking. -->
|
||||
<key>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyTrackingDomains</key>
|
||||
<array/>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array/>
|
||||
<array>
|
||||
<!-- The account and contract email, given when buying TREK Cloud and when signing up. -->
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypeEmailAddress</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
<true/>
|
||||
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||
<array>
|
||||
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||
</array>
|
||||
</dict>
|
||||
<!-- The name on a TREK Cloud account and of the people a trip is shared with. -->
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypeName</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
<true/>
|
||||
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||
<array>
|
||||
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||
</array>
|
||||
</dict>
|
||||
<!-- The App Store purchase of TREK Cloud, verified and kept with the contract. -->
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypePurchaseHistory</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
<true/>
|
||||
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||
<array>
|
||||
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||
</array>
|
||||
</dict>
|
||||
<!-- Photos the user adds to trips and journeys on a TREK Cloud instance. -->
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypePhotosorVideos</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
<true/>
|
||||
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||
<array>
|
||||
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||
</array>
|
||||
</dict>
|
||||
<!-- Trips, places, bookings, notes and files on a TREK Cloud instance. -->
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypeOtherUserContent</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
<true/>
|
||||
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||
<array>
|
||||
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||
</array>
|
||||
</dict>
|
||||
<!-- Places the user saves with their coordinates; the live location only when searching nearby. -->
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypePreciseLocation</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
<true/>
|
||||
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||
<array>
|
||||
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||
</array>
|
||||
</dict>
|
||||
<!-- The account on a TREK Cloud instance. -->
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypeUserID</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
<true/>
|
||||
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||
<array>
|
||||
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||
</array>
|
||||
</dict>
|
||||
<!-- The push token, handed to the push relay when notifications are on. -->
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypeDeviceID</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
<true/>
|
||||
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||
<array>
|
||||
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||
</array>
|
||||
</dict>
|
||||
<!-- Group chat messages and booking mails imported into a trip. -->
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypeEmailsOrTextMessages</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
<true/>
|
||||
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||
<array>
|
||||
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||
</array>
|
||||
</dict>
|
||||
<!-- Trip expenses, their splits and who owes whom (Budget). -->
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypeOtherFinancialInfo</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
<true/>
|
||||
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||
<array>
|
||||
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -26,6 +26,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
|
||||
func sceneDidBecomeActive(_ scene: UIScene) {
|
||||
TrekPrivacyCover.hide()
|
||||
// Whatever the share sheet left, even if it could not bring the app forward itself.
|
||||
TrekSharedFiles.receiveFromShareExtension()
|
||||
}
|
||||
|
||||
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
|
||||
@@ -33,7 +35,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
// else (the sign-in callback) stays with Capacitor.
|
||||
let files = URLContexts.filter { $0.url.isFileURL }
|
||||
for context in files { TrekSharedFiles.receive(context.url) }
|
||||
let others = URLContexts.subtracting(files)
|
||||
// com.liketrek.trek://share: the share sheet left an item for the import.
|
||||
let handoffs = URLContexts.filter { $0.url.scheme == "com.liketrek.trek" && $0.url.host == "share" }
|
||||
if !handoffs.isEmpty { TrekSharedFiles.receiveFromShareExtension() }
|
||||
let others = URLContexts.subtracting(files).subtracting(handoffs)
|
||||
if !others.isEmpty { SceneDelegateProxy.shared.scene(scene, openURLContexts: others) }
|
||||
}
|
||||
|
||||
@@ -90,6 +95,25 @@ enum TrekSharedFiles {
|
||||
onReceive?(file)
|
||||
}
|
||||
|
||||
/// The item the share extension stored (ShareExtension/ShareHandoff.swift), with
|
||||
/// the trip picked for it there. Taken once: the keychain is cleared right away.
|
||||
static func receiveFromShareExtension() {
|
||||
guard let meta = TrekSharedKeychain.read("share.pending.meta"),
|
||||
let data = TrekSharedKeychain.read("share.pending.data") else { return }
|
||||
TrekSharedKeychain.delete("share.pending.meta")
|
||||
TrekSharedKeychain.delete("share.pending.data")
|
||||
guard let info = try? JSONSerialization.jsonObject(with: meta) as? [String: Any],
|
||||
let name = info["name"] as? String, data.count <= maxBytes else { return }
|
||||
var file: [String: Any] = [
|
||||
"name": name,
|
||||
"mimeType": info["mimeType"] as? String ?? mimeType(for: (name as NSString).pathExtension.lowercased()),
|
||||
"data": data.base64EncodedString(),
|
||||
]
|
||||
if let tripId = info["tripId"] as? Int { file["tripId"] = tripId }
|
||||
pending = file
|
||||
onReceive?(file)
|
||||
}
|
||||
|
||||
static func take() -> [String: Any]? {
|
||||
defer { pending = nil }
|
||||
return pending
|
||||
|
||||
@@ -10,6 +10,7 @@ import WidgetKit
|
||||
import ActivityKit
|
||||
#endif
|
||||
import LocalAuthentication
|
||||
import PassKit
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
@@ -44,6 +45,9 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
CAPPluginMethod(name: "storePurchase", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "storeFinish", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "storeRestore", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "addToWallet", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "setWatchSnapshot", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "takeWatchActions", returnType: CAPPluginReturnPromise),
|
||||
]
|
||||
|
||||
/// What this binary offers the server's UI, raised with every native feature
|
||||
@@ -51,7 +55,11 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
/// and a server can require a minimum (minNativeShellApi in /api/health/features).
|
||||
static let shellApi = 2
|
||||
/// The native features by name, so the UI can turn each on by itself.
|
||||
static let features: [String] = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit"]
|
||||
/// "watch" only where WatchConnectivity is (an iPhone, not an iPad), so the UI never builds a snapshot for nothing.
|
||||
static var features: [String] {
|
||||
["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "wallet"]
|
||||
+ (TrekWatchLink.isAvailable ? ["watch"] : [])
|
||||
}
|
||||
|
||||
// MARK: - Shared booking documents
|
||||
|
||||
@@ -63,6 +71,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
DispatchQueue.main.async { self?.notifyListeners("pushOpened", data: ["url": url]) }
|
||||
}
|
||||
TrekStore.watchUpdates()
|
||||
TrekWatchLink.shared.onAction = { [weak self] in self?.notifyListeners("watchAction", data: [:]) }
|
||||
}
|
||||
|
||||
// MARK: - Widgets and the travel-day Live Activity
|
||||
@@ -135,6 +144,27 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
// MARK: - Apple Watch
|
||||
|
||||
/// Passes what the watch app shows (native/watch.ts builds it) on to the watch.
|
||||
@objc func setWatchSnapshot(_ call: CAPPluginCall) {
|
||||
guard TrekWatchLink.isAvailable else {
|
||||
call.resolve()
|
||||
return
|
||||
}
|
||||
guard let json = call.getString("json"), let data = json.data(using: .utf8) else {
|
||||
call.reject("json is required")
|
||||
return
|
||||
}
|
||||
TrekWatchLink.shared.publish(data)
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
/// The packing items and to-dos ticked on the watch since the last call, oldest first.
|
||||
@objc func takeWatchActions(_ call: CAPPluginCall) {
|
||||
call.resolve(["actions": TrekWatchLink.shared.takeActions()])
|
||||
}
|
||||
|
||||
// MARK: - Push
|
||||
|
||||
/// Asks for permission, registers with APNs and answers with a Web Push
|
||||
@@ -381,6 +411,58 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
|
||||
/// Positions and errors for the UI, shaped like the Geolocation API's, with the
|
||||
/// When-In-Use permission asked the first time.
|
||||
// MARK: - Apple Wallet
|
||||
|
||||
extension TrekShellPlugin {
|
||||
/// Feature wallet: a booking's Wallet pass, straight into Apple Wallet. A pass
|
||||
/// already there opens in Wallet instead of being offered twice. Resolves with
|
||||
/// `result` added, opened or cancelled; rejects INVALID for bytes that are no
|
||||
/// pass and UNAVAILABLE where Wallet cannot take one.
|
||||
@objc func addToWallet(_ call: CAPPluginCall) {
|
||||
guard let base64 = call.getString("data"), let data = Data(base64Encoded: base64),
|
||||
let pass = try? PKPass(data: data) else {
|
||||
call.reject("not a Wallet pass", "INVALID")
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
let library = PKPassLibrary()
|
||||
if library.containsPass(pass), let url = pass.passURL {
|
||||
UIApplication.shared.open(url)
|
||||
call.resolve(["result": "opened"])
|
||||
return
|
||||
}
|
||||
guard PKAddPassesViewController.canAddPasses(),
|
||||
let sheet = PKAddPassesViewController(pass: pass),
|
||||
let presenter = self?.bridge?.viewController else {
|
||||
call.reject("Wallet is not available", "UNAVAILABLE")
|
||||
return
|
||||
}
|
||||
let delegate = TrekWalletSheet { [weak sheet] in
|
||||
sheet?.dismiss(animated: true)
|
||||
call.resolve(["result": library.containsPass(pass) ? "added" : "cancelled"])
|
||||
}
|
||||
sheet.delegate = delegate
|
||||
TrekWalletSheet.current = delegate
|
||||
presenter.present(sheet, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Answers the Wallet sheet closing; held while it is up, since the sheet only keeps a weak delegate.
|
||||
final class TrekWalletSheet: NSObject, PKAddPassesViewControllerDelegate {
|
||||
static var current: TrekWalletSheet?
|
||||
private let done: () -> Void
|
||||
|
||||
init(done: @escaping () -> Void) {
|
||||
self.done = done
|
||||
}
|
||||
|
||||
func addPassesViewControllerDidFinish(_ controller: PKAddPassesViewController) {
|
||||
done()
|
||||
TrekWalletSheet.current = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - In-app purchase
|
||||
|
||||
extension TrekShellPlugin {
|
||||
@@ -389,12 +471,14 @@ extension TrekShellPlugin {
|
||||
/// know are left out, so the shell offers only what can really be bought.
|
||||
@objc func storeProducts(_ call: CAPPluginCall) {
|
||||
let ids = call.getArray("ids", String.self) ?? []
|
||||
// TestFlight, App Review and Xcode builds buy in the sandbox; their receipt says so.
|
||||
let sandbox = Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt"
|
||||
Task {
|
||||
guard AppStore.canMakePayments, let products = try? await Product.products(for: ids) else {
|
||||
call.resolve(["products": []])
|
||||
call.resolve(["products": [], "sandbox": sandbox])
|
||||
return
|
||||
}
|
||||
call.resolve(["products": products.map { ["id": $0.id, "displayPrice": $0.displayPrice] }])
|
||||
call.resolve(["products": products.map { ["id": $0.id, "displayPrice": $0.displayPrice] }, "sandbox": sandbox])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -716,6 +800,10 @@ enum TrekSharedKeychain {
|
||||
return SecItemCopyMatching(q as CFDictionary, &result) == errSecSuccess ? result as? Data : nil
|
||||
}
|
||||
|
||||
static func delete(_ account: String) {
|
||||
SecItemDelete(query(account) as CFDictionary)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func write(_ account: String, _ data: Data) -> Bool {
|
||||
SecItemDelete(query(account) as CFDictionary)
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import UIKit
|
||||
import WatchConnectivity
|
||||
|
||||
/// The phone's end of WatchConnectivity (feature "watch"). The UI hands over the
|
||||
/// watch's snapshot (client/src/native/watch.ts through TrekShell.setWatchSnapshot);
|
||||
/// this keeps the latest one, sends it as the application context, and as
|
||||
/// complication user info while the day's budget for those lasts and something
|
||||
/// a complication shows has changed. Ticks from the watch arrive as user info,
|
||||
/// are queued here until the UI takes them (TrekShell.takeWatchActions) and
|
||||
/// writes them through its offline-first path, and announced to it at once.
|
||||
final class TrekWatchLink: NSObject, WCSessionDelegate {
|
||||
static let shared = TrekWatchLink()
|
||||
|
||||
/// Told when a tick arrived, on the main queue.
|
||||
var onAction: (() -> Void)?
|
||||
|
||||
private let queue = DispatchQueue(label: "com.liketrek.trek.watch")
|
||||
private let snapshotKey = "trek.watch.snapshot"
|
||||
private let complicationKey = "trek.watch.complicationDigest"
|
||||
private let actionsKey = "trek.watch.actions"
|
||||
/// The most WatchConnectivity carries in one application context, with room to spare.
|
||||
private let maxBytes = 60_000
|
||||
|
||||
/// False on an iPad (and anything else without a paired watch): then this is
|
||||
/// a no-op throughout, and the UI is not offered the feature at all.
|
||||
static var isAvailable: Bool { WCSession.isSupported() }
|
||||
|
||||
func activate() {
|
||||
guard Self.isAvailable else { return }
|
||||
WCSession.default.delegate = self
|
||||
WCSession.default.activate()
|
||||
}
|
||||
|
||||
// MARK: Snapshots to the watch
|
||||
|
||||
func publish(_ json: Data) {
|
||||
guard Self.isAvailable else { return }
|
||||
let data = withSmallCover(json)
|
||||
queue.async {
|
||||
UserDefaults.standard.set(data, forKey: self.snapshotKey)
|
||||
self.send(data)
|
||||
}
|
||||
}
|
||||
|
||||
private func send(_ data: Data) {
|
||||
guard Self.isAvailable else { return }
|
||||
let session = WCSession.default
|
||||
guard session.activationState == .activated, session.isPaired, session.isWatchAppInstalled else { return }
|
||||
try? session.updateApplicationContext(["snapshot": data])
|
||||
let digest = complicationDigest(data)
|
||||
if session.isComplicationEnabled, session.remainingComplicationUserInfoTransfers > 0,
|
||||
digest != UserDefaults.standard.string(forKey: complicationKey) {
|
||||
session.transferCurrentComplicationUserInfo(["snapshot": data])
|
||||
UserDefaults.standard.set(digest, forKey: complicationKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// What the complications show of a snapshot: everything but when it was built and the applied ticks.
|
||||
private func complicationDigest(_ data: Data) -> String {
|
||||
guard var object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { return "" }
|
||||
object.removeValue(forKey: "generatedAt")
|
||||
object.removeValue(forKey: "applied")
|
||||
object.removeValue(forKey: "packing")
|
||||
object.removeValue(forKey: "todos")
|
||||
let canonical = (try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])) ?? Data()
|
||||
return SHA256.hash(data: canonical).map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
/// The trip's cover scaled down to a thumbnail the watch can draw, or dropped when it would not fit.
|
||||
private func withSmallCover(_ json: Data) -> Data {
|
||||
guard var object = (try? JSONSerialization.jsonObject(with: json)) as? [String: Any],
|
||||
var trip = object["trip"] as? [String: Any], let cover = trip["cover"] as? String else { return json }
|
||||
trip.removeValue(forKey: "cover")
|
||||
if let data = Data(base64Encoded: cover), let image = UIImage(data: data) {
|
||||
let width: CGFloat = 220
|
||||
let size = CGSize(width: width, height: (width * image.size.height / max(image.size.width, 1)).rounded())
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.scale = 1
|
||||
let small = UIGraphicsImageRenderer(size: size, format: format).image { _ in image.draw(in: CGRect(origin: .zero, size: size)) }
|
||||
if let jpeg = small.jpegData(compressionQuality: 0.6), jpeg.count < 24_000 {
|
||||
trip["cover"] = jpeg.base64EncodedString()
|
||||
}
|
||||
}
|
||||
object["trip"] = trip
|
||||
guard let result = try? JSONSerialization.data(withJSONObject: object) else { return json }
|
||||
if result.count > maxBytes, trip["cover"] != nil {
|
||||
trip.removeValue(forKey: "cover")
|
||||
object["trip"] = trip
|
||||
return (try? JSONSerialization.data(withJSONObject: object)) ?? json
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: Ticks from the watch
|
||||
|
||||
/// The ticks queued so far, oldest first, and the queue emptied.
|
||||
func takeActions() -> [[String: Any]] {
|
||||
queue.sync {
|
||||
let actions = storedActions()
|
||||
UserDefaults.standard.removeObject(forKey: actionsKey)
|
||||
return actions
|
||||
}
|
||||
}
|
||||
|
||||
private func storedActions() -> [[String: Any]] {
|
||||
guard let data = UserDefaults.standard.data(forKey: actionsKey),
|
||||
let list = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] else { return [] }
|
||||
return list
|
||||
}
|
||||
|
||||
private func enqueue(_ action: [String: Any]) {
|
||||
queue.async {
|
||||
// A tick sent twice (the watch resends after a restart) replaces the earlier copy.
|
||||
var actions = self.storedActions().filter { ($0["id"] as? String) != (action["id"] as? String) }
|
||||
actions.append(action)
|
||||
if let data = try? JSONSerialization.data(withJSONObject: Array(actions.suffix(200))) {
|
||||
UserDefaults.standard.set(data, forKey: self.actionsKey)
|
||||
}
|
||||
DispatchQueue.main.async { self.onAction?() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: WCSessionDelegate
|
||||
|
||||
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
|
||||
guard activationState == .activated else { return }
|
||||
queue.async {
|
||||
if let data = UserDefaults.standard.data(forKey: self.snapshotKey) { self.send(data) }
|
||||
}
|
||||
}
|
||||
|
||||
func sessionDidBecomeInactive(_ session: WCSession) {}
|
||||
|
||||
/// After switching watches the session has to be activated again for the new one.
|
||||
func sessionDidDeactivate(_ session: WCSession) {
|
||||
WCSession.default.activate()
|
||||
}
|
||||
|
||||
func sessionWatchStateDidChange(_ session: WCSession) {
|
||||
queue.async {
|
||||
if let data = UserDefaults.standard.data(forKey: self.snapshotKey) { self.send(data) }
|
||||
}
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
|
||||
if let action = userInfo["action"] as? [String: Any] { enqueue(action) }
|
||||
}
|
||||
|
||||
/// The watch asks for the latest snapshot when it opens while the phone is in reach.
|
||||
func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) {
|
||||
queue.async {
|
||||
if message["request"] as? String == "snapshot", let data = UserDefaults.standard.data(forKey: self.snapshotKey) {
|
||||
replyHandler(["snapshot": data])
|
||||
} else {
|
||||
replyHandler([:])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>TREK</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationRule</key>
|
||||
<string>SUBQUERY (
|
||||
extensionItems,
|
||||
$extensionItem,
|
||||
SUBQUERY (
|
||||
$extensionItem.attachments,
|
||||
$attachment,
|
||||
ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.apple.pkpass"
|
||||
|| ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.adobe.pdf"
|
||||
|| ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.email-message"
|
||||
|| ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.apple.mail.email"
|
||||
|| ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.html"
|
||||
|| ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url"
|
||||
|| ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.plain-text"
|
||||
|| ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.image"
|
||||
).@count == 1
|
||||
).@count == 1</string>
|
||||
<key>NSExtensionJavaScriptPreprocessingFile</key>
|
||||
<string>SharePage</string>
|
||||
</dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.share-services</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>ShareViewController</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.liketrek.trek.shared</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,93 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// The trips the share sheet offers, and the hand-over of the shared item to the
|
||||
/// app. Both go through the keychain group the app and its extensions share
|
||||
/// (TrekSharedKeychain in the app): the app keeps its trip list there for the
|
||||
/// widgets already, and the app reads the item back the moment it opens
|
||||
/// (TrekSharedFiles.receiveFromShareExtension).
|
||||
enum ShareHandoff {
|
||||
private static let service = "com.liketrek.trek.push"
|
||||
static let metaAccount = "share.pending.meta"
|
||||
static let dataAccount = "share.pending.data"
|
||||
|
||||
struct Trip: Identifiable, Equatable {
|
||||
var id: Int
|
||||
var title: String
|
||||
var start: Date?
|
||||
var end: Date?
|
||||
var place: String?
|
||||
/// A small cover photo, from the trip's widget snapshot when the app wrote one.
|
||||
var cover: Data?
|
||||
}
|
||||
|
||||
private struct TripRef: Decodable {
|
||||
var id: Int
|
||||
var title: String
|
||||
var start: String
|
||||
var end: String
|
||||
}
|
||||
|
||||
private struct Snapshot: Decodable {
|
||||
struct Trip: Decodable {
|
||||
var place: String?
|
||||
var cover: String?
|
||||
}
|
||||
var trip: Trip?
|
||||
}
|
||||
|
||||
/// The trips that have not ended yet, soonest first, then the most recent past ones.
|
||||
static func trips(now: Date = Date()) -> [Trip] {
|
||||
guard let raw = read("widget.trips"), let refs = try? JSONDecoder().decode([TripRef].self, from: raw) else { return [] }
|
||||
let today = Calendar.current.startOfDay(for: now)
|
||||
let all = refs.map { ref -> Trip in
|
||||
let snapshot = read("widget.trip.\(ref.id)").flatMap { try? JSONDecoder().decode(Snapshot.self, from: $0) }
|
||||
return Trip(id: ref.id, title: ref.title, start: day(ref.start), end: day(ref.end),
|
||||
place: snapshot?.trip?.place, cover: snapshot?.trip?.cover.flatMap { Data(base64Encoded: $0) })
|
||||
}
|
||||
let ahead = all.filter { ($0.end ?? .distantFuture) >= today }.sorted { ($0.start ?? .distantFuture) < ($1.start ?? .distantFuture) }
|
||||
let past = all.filter { ($0.end ?? .distantFuture) < today }.sorted { ($0.end ?? .distantPast) > ($1.end ?? .distantPast) }
|
||||
return ahead + past.prefix(6)
|
||||
}
|
||||
|
||||
/// Leaves the item for the app, with the trip it goes to (nil: the app asks).
|
||||
static func store(_ item: ShareItem, tripId: Int?) -> Bool {
|
||||
var meta: [String: Any] = ["name": item.name, "mimeType": item.mimeType]
|
||||
if let tripId { meta["tripId"] = tripId }
|
||||
guard let metaData = try? JSONSerialization.data(withJSONObject: meta) else { return false }
|
||||
return write(dataAccount, item.data) && write(metaAccount, metaData)
|
||||
}
|
||||
|
||||
private static func day(_ iso: String) -> Date? {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = Calendar(identifier: .gregorian)
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter.date(from: String(iso.prefix(10)))
|
||||
}
|
||||
|
||||
// The default access group is the first one in the entitlement, the shared one.
|
||||
private static func query(_ account: String) -> [String: Any] {
|
||||
[
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
}
|
||||
|
||||
private static func read(_ account: String) -> Data? {
|
||||
var q = query(account)
|
||||
q[kSecReturnData as String] = true
|
||||
var result: CFTypeRef?
|
||||
guard SecItemCopyMatching(q as CFDictionary, &result) == errSecSuccess else { return nil }
|
||||
return result as? Data
|
||||
}
|
||||
|
||||
private static func write(_ account: String, _ data: Data) -> Bool {
|
||||
SecItemDelete(query(account) as CFDictionary)
|
||||
var q = query(account)
|
||||
q[kSecValueData as String] = data
|
||||
q[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||
return SecItemAdd(q as CFDictionary, nil) == errSecSuccess
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// What was shared to TREK, read into a file the booking import takes: a Wallet
|
||||
/// pass, a PDF, a mail, a web page as Safari rendered it, a piece of text or a
|
||||
/// photo. The app's booking import (BookingImportModal) decides what it can read;
|
||||
/// this only carries the bytes over with a name and a type.
|
||||
struct ShareItem {
|
||||
enum Kind: String {
|
||||
case pass, pdf, mail, page, text, image, file
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .pass: return "wallet.pass.fill"
|
||||
case .pdf: return "doc.richtext.fill"
|
||||
case .mail: return "envelope.fill"
|
||||
case .page: return "safari.fill"
|
||||
case .text: return "text.alignleft"
|
||||
case .image: return "photo.fill"
|
||||
case .file: return "doc.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var kind: Kind
|
||||
/// The file name the import sees.
|
||||
var name: String
|
||||
var mimeType: String
|
||||
var data: Data
|
||||
/// What the sheet shows: a page title, a file name, the first line of a text.
|
||||
var title: String
|
||||
/// A host, for a page.
|
||||
var source: String?
|
||||
|
||||
var sizeLabel: String {
|
||||
ByteCountFormatter.string(fromByteCount: Int64(data.count), countStyle: .file)
|
||||
}
|
||||
}
|
||||
|
||||
enum ShareLoadError: Error {
|
||||
case unsupported
|
||||
case tooLarge
|
||||
}
|
||||
|
||||
enum ShareLoader {
|
||||
/// The keychain carries the item to the app, so it has to stay modest; a
|
||||
/// booking confirmation is far below this.
|
||||
static let maxBytes = 10 * 1024 * 1024
|
||||
|
||||
private static let passType = "com.apple.pkpass"
|
||||
private static let mailTypes = ["com.apple.mail.email", "public.email-message"]
|
||||
|
||||
static func load(_ items: [NSExtensionItem]) async throws -> ShareItem {
|
||||
let providers = items.flatMap { $0.attachments ?? [] }
|
||||
guard !providers.isEmpty else { throw ShareLoadError.unsupported }
|
||||
|
||||
// Safari hands over the page as it is rendered (SharePage.js), which also
|
||||
// covers confirmation pages behind a sign-in that a fresh download would miss.
|
||||
for provider in providers where provider.hasItemConformingToTypeIdentifier(UTType.propertyList.identifier) {
|
||||
if let page = try? await pageFromSafari(provider) { return page }
|
||||
}
|
||||
for provider in providers where provider.hasItemConformingToTypeIdentifier(passType) {
|
||||
return try await file(provider, type: passType, kind: .pass, ext: "pkpass", mime: "application/vnd.apple.pkpass")
|
||||
}
|
||||
for provider in providers where provider.hasItemConformingToTypeIdentifier(UTType.pdf.identifier) {
|
||||
return try await file(provider, type: UTType.pdf.identifier, kind: .pdf, ext: "pdf", mime: "application/pdf")
|
||||
}
|
||||
for type in mailTypes {
|
||||
for provider in providers where provider.hasItemConformingToTypeIdentifier(type) {
|
||||
return try await file(provider, type: type, kind: .mail, ext: "eml", mime: "message/rfc822")
|
||||
}
|
||||
}
|
||||
for provider in providers where provider.hasItemConformingToTypeIdentifier(UTType.html.identifier) {
|
||||
return try await file(provider, type: UTType.html.identifier, kind: .page, ext: "html", mime: "text/html")
|
||||
}
|
||||
for provider in providers where provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
|
||||
return try await image(provider)
|
||||
}
|
||||
for provider in providers where provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
|
||||
if let url = try await provider.loadShared(UTType.url.identifier) as? URL {
|
||||
if url.isFileURL { return try fromFile(url) }
|
||||
return try await page(at: url)
|
||||
}
|
||||
}
|
||||
for provider in providers where provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
|
||||
let item = try await provider.loadShared(UTType.plainText.identifier)
|
||||
let text = (item as? String) ?? (item as? Data).flatMap { String(data: $0, encoding: .utf8) } ?? ""
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { throw ShareLoadError.unsupported }
|
||||
let firstLine = trimmed.split(whereSeparator: \.isNewline).first.map(String.init) ?? trimmed
|
||||
return try checked(ShareItem(kind: .text, name: "shared-text.txt", mimeType: "text/plain",
|
||||
data: Data(trimmed.utf8), title: String(firstLine.prefix(80)), source: nil))
|
||||
}
|
||||
throw ShareLoadError.unsupported
|
||||
}
|
||||
|
||||
// MARK: - Kinds
|
||||
|
||||
private static func file(_ provider: NSItemProvider, type: String, kind: ShareItem.Kind, ext: String, mime: String) async throws -> ShareItem {
|
||||
let item = try await provider.loadShared(type)
|
||||
var name = provider.suggestedName.map { $0.hasSuffix(".\(ext)") ? $0 : "\($0).\(ext)" } ?? "booking.\(ext)"
|
||||
let data: Data
|
||||
if let url = item as? URL {
|
||||
data = try read(url)
|
||||
name = url.lastPathComponent
|
||||
} else if let raw = item as? Data {
|
||||
data = raw
|
||||
} else {
|
||||
throw ShareLoadError.unsupported
|
||||
}
|
||||
return try checked(ShareItem(kind: kind, name: name, mimeType: mime, data: data,
|
||||
title: (name as NSString).deletingPathExtension, source: nil))
|
||||
}
|
||||
|
||||
private static func image(_ provider: NSItemProvider) async throws -> ShareItem {
|
||||
let item = try await provider.loadShared(UTType.image.identifier)
|
||||
var picture: UIImage?
|
||||
if let url = item as? URL { picture = UIImage(data: try read(url)) }
|
||||
if let data = item as? Data { picture = UIImage(data: data) }
|
||||
if let shown = item as? UIImage { picture = shown }
|
||||
guard let picture, let jpeg = picture.jpegData(compressionQuality: 0.85) else { throw ShareLoadError.unsupported }
|
||||
let name = (provider.suggestedName ?? "photo") + ".jpg"
|
||||
return try checked(ShareItem(kind: .image, name: name, mimeType: "image/jpeg", data: jpeg,
|
||||
title: provider.suggestedName ?? name, source: nil))
|
||||
}
|
||||
|
||||
private static func pageFromSafari(_ provider: NSItemProvider) async throws -> ShareItem? {
|
||||
let item = try await provider.loadShared(UTType.propertyList.identifier)
|
||||
guard let dict = item as? NSDictionary,
|
||||
let results = dict[NSExtensionJavaScriptPreprocessingResultsKey] as? [String: Any],
|
||||
let html = results["html"] as? String, !html.isEmpty else { return nil }
|
||||
let url = (results["url"] as? String).flatMap(URL.init(string:))
|
||||
let title = (results["title"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? url?.host ?? "Page"
|
||||
return try checked(ShareItem(kind: .page, name: "\(slug(title)).html", mimeType: "text/html",
|
||||
data: Data(html.utf8), title: title, source: url?.host))
|
||||
}
|
||||
|
||||
/// A link shared from an app other than Safari: the page as the server sends it.
|
||||
private static func page(at url: URL) async throws -> ShareItem {
|
||||
guard url.scheme == "https" || url.scheme == "http" else { throw ShareLoadError.unsupported }
|
||||
var request = URLRequest(url: url, timeoutInterval: 10)
|
||||
request.setValue("text/html", forHTTPHeaderField: "Accept")
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode),
|
||||
(http.mimeType ?? "").contains("html") else { throw ShareLoadError.unsupported }
|
||||
let html = String(decoding: data, as: UTF8.self)
|
||||
let title = pageTitle(html) ?? url.host ?? "Page"
|
||||
return try checked(ShareItem(kind: .page, name: "\(slug(title)).html", mimeType: "text/html",
|
||||
data: data, title: title, source: url.host))
|
||||
}
|
||||
|
||||
private static func fromFile(_ url: URL) throws -> ShareItem {
|
||||
let data = try read(url)
|
||||
let ext = url.pathExtension.lowercased()
|
||||
let (kind, mime): (ShareItem.Kind, String) = {
|
||||
switch ext {
|
||||
case "pkpass": return (.pass, "application/vnd.apple.pkpass")
|
||||
case "pdf": return (.pdf, "application/pdf")
|
||||
case "eml": return (.mail, "message/rfc822")
|
||||
case "html", "htm": return (.page, "text/html")
|
||||
case "txt": return (.text, "text/plain")
|
||||
default: return (.file, "application/octet-stream")
|
||||
}
|
||||
}()
|
||||
return try checked(ShareItem(kind: kind, name: url.lastPathComponent, mimeType: mime, data: data,
|
||||
title: url.deletingPathExtension().lastPathComponent, source: nil))
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private static func read(_ url: URL) throws -> Data {
|
||||
let scoped = url.startAccessingSecurityScopedResource()
|
||||
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
|
||||
if let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize, size > maxBytes {
|
||||
throw ShareLoadError.tooLarge
|
||||
}
|
||||
return try Data(contentsOf: url)
|
||||
}
|
||||
|
||||
private static func checked(_ item: ShareItem) throws -> ShareItem {
|
||||
guard !item.data.isEmpty else { throw ShareLoadError.unsupported }
|
||||
guard item.data.count <= maxBytes else { throw ShareLoadError.tooLarge }
|
||||
return item
|
||||
}
|
||||
|
||||
private static func pageTitle(_ html: String) -> String? {
|
||||
guard let open = html.range(of: "<title", options: .caseInsensitive),
|
||||
let start = html.range(of: ">", range: open.upperBound..<html.endIndex),
|
||||
let end = html.range(of: "</title>", options: .caseInsensitive, range: start.upperBound..<html.endIndex)
|
||||
else { return nil }
|
||||
let title = html[start.upperBound..<end.lowerBound].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return title.isEmpty ? nil : String(title.prefix(120))
|
||||
}
|
||||
|
||||
private static func slug(_ title: String) -> String {
|
||||
let cleaned = title.lowercased().components(separatedBy: CharacterSet.alphanumerics.inverted).filter { !$0.isEmpty }
|
||||
let joined = cleaned.prefix(8).joined(separator: "-")
|
||||
return joined.isEmpty ? "page" : joined
|
||||
}
|
||||
}
|
||||
|
||||
private extension NSItemProvider {
|
||||
func loadShared(_ type: String) async throws -> NSSecureCoding? {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
loadItem(forTypeIdentifier: type, options: nil) { item, error in
|
||||
if let error { continuation.resume(throwing: error) } else { continuation.resume(returning: item) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Safari runs this in the page before the share sheet opens (Info.plist,
|
||||
// NSExtensionJavaScriptPreprocessingFile). It hands over the page as the user
|
||||
// sees it, so a booking confirmation behind a sign-in reaches TREK whole.
|
||||
var SharePage = function () {};
|
||||
|
||||
SharePage.prototype = {
|
||||
run: function (args) {
|
||||
args.completionFunction({
|
||||
html: document.documentElement.outerHTML,
|
||||
title: document.title,
|
||||
url: document.URL,
|
||||
});
|
||||
},
|
||||
finalize: function () {},
|
||||
};
|
||||
|
||||
var ExtensionPreprocessingJS = new SharePage();
|
||||
@@ -0,0 +1,427 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The sheet: what was shared, the trip it goes to, and one button. It rises over
|
||||
/// the app that shared, in the system material, and answers the tap with a
|
||||
/// checkmark before TREK comes forward.
|
||||
struct ShareView: View {
|
||||
@ObservedObject var model: ShareModel
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
@State private var shown = false
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
Color.black.opacity(shown ? 0.28 : 0)
|
||||
.ignoresSafeArea()
|
||||
.onTapGesture { dismiss() }
|
||||
|
||||
card
|
||||
.offset(y: shown ? 0 : 520)
|
||||
}
|
||||
.onAppear {
|
||||
withAnimation(.spring(response: 0.5, dampingFraction: 0.86)) { shown = true }
|
||||
}
|
||||
}
|
||||
|
||||
private func dismiss() {
|
||||
withAnimation(.easeIn(duration: 0.2)) { shown = false }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { model.onCancel() }
|
||||
}
|
||||
|
||||
// MARK: - Card
|
||||
|
||||
private var card: some View {
|
||||
VStack(spacing: 0) {
|
||||
Capsule()
|
||||
.fill(ShareStyle.faint(scheme).opacity(0.5))
|
||||
.frame(width: 38, height: 5)
|
||||
.padding(.top, 8)
|
||||
|
||||
header
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 14)
|
||||
|
||||
Group {
|
||||
switch model.phase {
|
||||
case .failed(let message):
|
||||
failure(message)
|
||||
default:
|
||||
content
|
||||
}
|
||||
}
|
||||
.padding(.top, 16)
|
||||
}
|
||||
.padding(.bottom, 12)
|
||||
.frame(maxWidth: 560)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 32, style: .continuous)
|
||||
.fill(.regularMaterial)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 32, style: .continuous)
|
||||
.strokeBorder(ShareStyle.edge(scheme), lineWidth: 1)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.25), radius: 30, y: 10)
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
)
|
||||
.padding(.horizontal, 6)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 12) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 11, style: .continuous)
|
||||
.fill(ShareStyle.ink(scheme))
|
||||
Text("T")
|
||||
.font(.system(size: 20, weight: .heavy, design: .rounded))
|
||||
.foregroundColor(ShareStyle.paper(scheme))
|
||||
}
|
||||
.frame(width: 38, height: 38)
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("TREK")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.tracking(2.4)
|
||||
.foregroundColor(ShareStyle.muted(scheme))
|
||||
Text(L("share.title"))
|
||||
.font(.system(size: 19, weight: .bold))
|
||||
.foregroundColor(ShareStyle.ink(scheme))
|
||||
}
|
||||
Spacer()
|
||||
Button(action: dismiss) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.foregroundColor(ShareStyle.muted(scheme))
|
||||
.frame(width: 32, height: 32)
|
||||
.background(Circle().fill(ShareStyle.track(scheme)))
|
||||
}
|
||||
.accessibilityLabel(L("share.cancel"))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
itemCard
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(L("share.trip").uppercased())
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.tracking(1.2)
|
||||
.foregroundColor(ShareStyle.faint(scheme))
|
||||
.padding(.horizontal, 22)
|
||||
if model.trips.isEmpty {
|
||||
Text(L("share.noTrips"))
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(ShareStyle.muted(scheme))
|
||||
.padding(.horizontal, 22)
|
||||
} else {
|
||||
tripRail
|
||||
}
|
||||
}
|
||||
|
||||
Text(L("share.hint"))
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(ShareStyle.faint(scheme))
|
||||
.frame(maxWidth: .infinity)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
addButton
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The shared item
|
||||
|
||||
private var itemCard: some View {
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(LinearGradient(colors: ShareStyle.kindColors(model.item?.kind ?? .file),
|
||||
startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||
Image(systemName: (model.item?.kind ?? .file).symbol)
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.frame(width: 54, height: 54)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(model.item?.title ?? L("share.reading"))
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundColor(ShareStyle.ink(scheme))
|
||||
.lineLimit(2)
|
||||
HStack(spacing: 6) {
|
||||
if let item = model.item {
|
||||
badge(L("share.kind.\(item.kind.rawValue)"))
|
||||
if let source = item.source { badge(source) }
|
||||
badge(item.sizeLabel)
|
||||
} else {
|
||||
ProgressView().scaleEffect(0.8)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.fill(ShareStyle.raised(scheme))
|
||||
)
|
||||
.redacted(reason: model.item == nil ? .placeholder : [])
|
||||
}
|
||||
|
||||
private func badge(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundColor(ShareStyle.muted(scheme))
|
||||
.lineLimit(1)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Capsule().fill(ShareStyle.track(scheme)))
|
||||
}
|
||||
|
||||
// MARK: - Trips
|
||||
|
||||
private var tripRail: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(model.trips) { trip in
|
||||
tripCard(trip)
|
||||
.id(trip.id)
|
||||
}
|
||||
laterCard
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.onAppear {
|
||||
if let id = model.selectedTrip { proxy.scrollTo(id, anchor: .leading) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func tripCard(_ trip: ShareHandoff.Trip) -> some View {
|
||||
let selected = model.selectedTrip == trip.id
|
||||
return Button {
|
||||
UISelectionFeedbackGenerator().selectionChanged()
|
||||
withAnimation(.spring(response: 0.32, dampingFraction: 0.7)) { model.selectedTrip = trip.id }
|
||||
} label: {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
cover(trip)
|
||||
LinearGradient(colors: [.clear, .black.opacity(0.7)], startPoint: .center, endPoint: .bottom)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(trip.title)
|
||||
.font(.system(size: 15, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
if let place = trip.place {
|
||||
Text(place)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(11)
|
||||
}
|
||||
.frame(width: 156, height: 118)
|
||||
.overlay(alignment: .topLeading) {
|
||||
if let when = relative(trip) {
|
||||
Text(when)
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 3)
|
||||
.background(Capsule().fill(.black.opacity(0.35)))
|
||||
.padding(9)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if selected {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.symbolRenderingMode(.palette)
|
||||
.foregroundStyle(ShareStyle.ink(.light), .white)
|
||||
.padding(7)
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.strokeBorder(selected ? ShareStyle.ink(scheme) : .clear, lineWidth: 2.5)
|
||||
)
|
||||
.scaleEffect(selected ? 1 : 0.96)
|
||||
.opacity(selected || model.selectedTrip == nil ? 1 : 0.78)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityAddTraits(selected ? .isSelected : [])
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func cover(_ trip: ShareHandoff.Trip) -> some View {
|
||||
if let data = trip.cover, let image = UIImage(data: data) {
|
||||
Image(uiImage: image).resizable().scaledToFill()
|
||||
} else {
|
||||
ZStack {
|
||||
LinearGradient(colors: ShareStyle.tripColors(trip.id), startPoint: .topLeading, endPoint: .bottomTrailing)
|
||||
Image(systemName: "airplane")
|
||||
.font(.system(size: 34, weight: .semibold))
|
||||
.foregroundColor(.white.opacity(0.22))
|
||||
.rotationEffect(.degrees(-20))
|
||||
.offset(x: 40, y: -18)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var laterCard: some View {
|
||||
let selected = model.selectedTrip == nil
|
||||
return Button {
|
||||
UISelectionFeedbackGenerator().selectionChanged()
|
||||
withAnimation(.spring(response: 0.32, dampingFraction: 0.7)) { model.selectedTrip = nil }
|
||||
} label: {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "rectangle.stack.badge.plus")
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
Text(L("share.later"))
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.foregroundColor(ShareStyle.muted(scheme))
|
||||
.frame(width: 118, height: 118)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.strokeBorder(selected ? ShareStyle.ink(scheme) : ShareStyle.faint(scheme).opacity(0.5),
|
||||
style: StrokeStyle(lineWidth: selected ? 2.5 : 1.5, dash: selected ? [] : [5, 4]))
|
||||
)
|
||||
.scaleEffect(selected ? 1 : 0.96)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func relative(_ trip: ShareHandoff.Trip) -> String? {
|
||||
let calendar = Calendar.current
|
||||
let today = calendar.startOfDay(for: Date())
|
||||
guard let start = trip.start else { return nil }
|
||||
let end = trip.end ?? start
|
||||
if today >= calendar.startOfDay(for: start), today <= calendar.startOfDay(for: end) { return L("share.now") }
|
||||
if today > calendar.startOfDay(for: end) { return L("share.past") }
|
||||
let days = calendar.dateComponents([.day], from: today, to: calendar.startOfDay(for: start)).day ?? 0
|
||||
if days == 1 { return L("share.tomorrow") }
|
||||
return String(format: L("share.inDays"), days)
|
||||
}
|
||||
|
||||
// MARK: - Button and failure
|
||||
|
||||
private var addButton: some View {
|
||||
Button(action: model.add) {
|
||||
ZStack {
|
||||
switch model.phase {
|
||||
case .saving:
|
||||
ProgressView().tint(ShareStyle.paper(scheme))
|
||||
case .done:
|
||||
Label(L("share.added"), systemImage: "checkmark")
|
||||
.font(.system(size: 17, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.transition(.scale(scale: 0.6).combined(with: .opacity))
|
||||
default:
|
||||
Text(buttonTitle)
|
||||
.font(.system(size: 17, weight: .bold))
|
||||
.foregroundColor(ShareStyle.paper(scheme))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 54)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 17, style: .continuous)
|
||||
.fill(model.phase == .done ? ShareStyle.success : ShareStyle.ink(scheme))
|
||||
)
|
||||
.scaleEffect(model.phase == .done ? 1.02 : 1)
|
||||
}
|
||||
.buttonStyle(PressStyle())
|
||||
.disabled(model.phase != .ready)
|
||||
.opacity(model.phase == .loading ? 0.5 : 1)
|
||||
}
|
||||
|
||||
private var buttonTitle: String {
|
||||
if let title = model.selectedTitle { return String(format: L("share.addTo"), title) }
|
||||
return L("share.add")
|
||||
}
|
||||
|
||||
private func failure(_ message: String) -> some View {
|
||||
VStack(spacing: 14) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 30, weight: .semibold))
|
||||
.foregroundColor(ShareStyle.warning)
|
||||
Text(message)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundColor(ShareStyle.ink(scheme))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 28)
|
||||
Button(action: dismiss) {
|
||||
Text(L("share.close"))
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundColor(ShareStyle.ink(scheme))
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 50)
|
||||
.background(RoundedRectangle(cornerRadius: 16, style: .continuous).fill(ShareStyle.track(scheme)))
|
||||
}
|
||||
.buttonStyle(PressStyle())
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
|
||||
/// A press that gives a little under the thumb.
|
||||
private struct PressStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? 0.97 : 1)
|
||||
.animation(.spring(response: 0.25, dampingFraction: 0.7), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
|
||||
/// The app's neutral palette (the same values as TREKWidgets/TrekStyle.swift).
|
||||
enum ShareStyle {
|
||||
static func ink(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color(hex: 0xF5F5F7) : Color(hex: 0x101013) }
|
||||
static func paper(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color(hex: 0x101013) : Color(hex: 0xFFFFFF) }
|
||||
static func muted(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color(hex: 0x9C9CA3) : Color(hex: 0x68686F) }
|
||||
static func faint(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color(hex: 0x6E6E76) : Color(hex: 0x9A9AA1) }
|
||||
static func track(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color.white.opacity(0.1) : Color(hex: 0x101013).opacity(0.06) }
|
||||
static func raised(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color.white.opacity(0.06) : Color.white.opacity(0.7) }
|
||||
static func edge(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color.white.opacity(0.08) : Color.black.opacity(0.05) }
|
||||
static let success = Color(hex: 0x2FA37A)
|
||||
static let warning = Color(hex: 0xE8A13A)
|
||||
|
||||
static func kindColors(_ kind: ShareItem.Kind) -> [Color] {
|
||||
switch kind {
|
||||
case .pass: return [Color(hex: 0x1F1F24), Color(hex: 0x3A3A44)]
|
||||
case .pdf: return [Color(hex: 0xE5484D), Color(hex: 0xB4232A)]
|
||||
case .mail: return [Color(hex: 0x4A9DFF), Color(hex: 0x1F6FEB)]
|
||||
case .page: return [Color(hex: 0x38B2F5), Color(hex: 0x1A7FD6)]
|
||||
case .text: return [Color(hex: 0x8E8E96), Color(hex: 0x5D5D66)]
|
||||
case .image: return [Color(hex: 0xF2A93B), Color(hex: 0xE0662E)]
|
||||
case .file: return [Color(hex: 0x7C7C86), Color(hex: 0x4D4D57)]
|
||||
}
|
||||
}
|
||||
|
||||
/// A trip without a cover gets a gradient of its own, steady across opens.
|
||||
static func tripColors(_ id: Int) -> [Color] {
|
||||
let base = Double((id &* 47) % 360) / 360
|
||||
return [
|
||||
Color(hue: base, saturation: 0.6, brightness: 0.78),
|
||||
Color(hue: (base + 0.11).truncatingRemainder(dividingBy: 1), saturation: 0.7, brightness: 0.42),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
private extension Color {
|
||||
init(hex: UInt32) {
|
||||
self.init(red: Double((hex >> 16) & 0xFF) / 255, green: Double((hex >> 8) & 0xFF) / 255, blue: Double(hex & 0xFF) / 255)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// The share sheet's entry point ("TREK" in the share sheet of Mail, Safari,
|
||||
/// Files, Wallet and any app that shares a document or a link). It reads what
|
||||
/// was shared, lets the user pick the trip it belongs to, leaves both for the
|
||||
/// app (ShareHandoff) and opens TREK, where the booking import takes over.
|
||||
@objc(ShareViewController)
|
||||
final class ShareViewController: UIViewController {
|
||||
private let model = ShareModel()
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .clear
|
||||
|
||||
model.onCancel = { [weak self] in
|
||||
self?.extensionContext?.cancelRequest(withError: NSError(domain: "com.liketrek.trek.share", code: 0))
|
||||
}
|
||||
model.onDone = { [weak self] in
|
||||
self?.openApp()
|
||||
self?.extensionContext?.completeRequest(returningItems: nil)
|
||||
}
|
||||
|
||||
let host = UIHostingController(rootView: ShareView(model: model))
|
||||
host.view.backgroundColor = .clear
|
||||
addChild(host)
|
||||
host.view.frame = view.bounds
|
||||
host.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
view.addSubview(host.view)
|
||||
host.didMove(toParent: self)
|
||||
|
||||
let items = extensionContext?.inputItems.compactMap { $0 as? NSExtensionItem } ?? []
|
||||
Task { await model.load(items) }
|
||||
}
|
||||
|
||||
/// Extensions cannot reach UIApplication.shared; the application is still
|
||||
/// up the responder chain, and opening our own scheme brings TREK forward.
|
||||
/// Should that ever fail, the app picks the item up the next time it opens.
|
||||
private func openApp() {
|
||||
guard let url = URL(string: "com.liketrek.trek://share") else { return }
|
||||
var responder: UIResponder? = self
|
||||
while let current = responder {
|
||||
if let application = current as? UIApplication {
|
||||
application.open(url, options: [:], completionHandler: nil)
|
||||
return
|
||||
}
|
||||
responder = current.next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ShareModel: ObservableObject {
|
||||
enum Phase: Equatable {
|
||||
case loading
|
||||
case ready
|
||||
case saving
|
||||
case done
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
@Published var phase: Phase = .loading
|
||||
@Published var item: ShareItem?
|
||||
@Published var trips: [ShareHandoff.Trip] = []
|
||||
/// nil: the app asks which trip once it is open.
|
||||
@Published var selectedTrip: Int?
|
||||
|
||||
var onCancel: () -> Void = {}
|
||||
var onDone: () -> Void = {}
|
||||
|
||||
func load(_ items: [NSExtensionItem]) async {
|
||||
trips = ShareHandoff.trips()
|
||||
selectedTrip = trips.first?.id
|
||||
do {
|
||||
item = try await ShareLoader.load(items)
|
||||
phase = .ready
|
||||
} catch ShareLoadError.tooLarge {
|
||||
phase = .failed(L("share.tooLarge"))
|
||||
} catch {
|
||||
phase = .failed(L("share.unsupported"))
|
||||
}
|
||||
}
|
||||
|
||||
func add() {
|
||||
guard let item, phase == .ready else { return }
|
||||
phase = .saving
|
||||
let haptics = UINotificationFeedbackGenerator()
|
||||
haptics.prepare()
|
||||
Task {
|
||||
guard ShareHandoff.store(item, tripId: selectedTrip) else {
|
||||
haptics.notificationOccurred(.error)
|
||||
phase = .failed(L("share.failed"))
|
||||
return
|
||||
}
|
||||
haptics.notificationOccurred(.success)
|
||||
withAnimation(.spring(response: 0.42, dampingFraction: 0.72)) { phase = .done }
|
||||
try? await Task.sleep(nanoseconds: 900_000_000)
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
|
||||
var selectedTitle: String? {
|
||||
trips.first { $0.id == selectedTrip }?.title
|
||||
}
|
||||
}
|
||||
|
||||
/// The extension's own strings (ShareExtension/<lang>.lproj/Localizable.strings).
|
||||
func L(_ key: String) -> String {
|
||||
NSLocalizedString(key, comment: "")
|
||||
}
|
||||