mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-12 15:21:08 +00:00
Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f81fe8cb17 | |||
| 7e8e342e29 | |||
| 445fb173fb | |||
| b317877c41 | |||
| 27c19b5ff5 | |||
| ffd848e5dc | |||
| 45361c276c | |||
| 1b24e3dcef | |||
| 69d88ffefd | |||
| 92fc8ef28f | |||
| 2aa25e3fe7 | |||
| f6bcdb8e6f | |||
| 867a52bc0d | |||
| ec87f280c3 | |||
| 6e362de3da | |||
| 654900bc50 | |||
| 75d0a6181b | |||
| 6de15a92a4 | |||
| b15dcd9730 | |||
| 821f23aed4 | |||
| 79420cd945 | |||
| e3541535e2 | |||
| 431cc226e3 | |||
| 6835fd7bf3 | |||
| 3c4c880c31 | |||
| f35b189ca2 | |||
| c6f91716a4 | |||
| ad69bbf4a9 | |||
| 30158cddc3 | |||
| 825469a7f2 | |||
| 56f9a92054 | |||
| 017f229cfc | |||
| dec6babe1d | |||
| d859e23879 | |||
| f92aea80a7 | |||
| 7b29a5d34e | |||
| c7a369a0ca | |||
| 117af0f9b1 | |||
| 496c85e6e5 | |||
| 1c15c33cf9 | |||
| 50bd1bcbc1 | |||
| d2ae9a87c4 | |||
| c5cb39c27b | |||
| 6d093d1102 | |||
| 01ddcc63bb | |||
| b07b38b5b7 | |||
| 83f0bd2ead | |||
| 7293ee1174 | |||
| 25351c3628 | |||
| d18fb7f63d | |||
| 1054f735ad | |||
| 954d419671 | |||
| 2077b9e375 | |||
| 5135052446 | |||
| f9e1dfe804 | |||
| bd42661a62 | |||
| 3b4d220936 | |||
| e3ac025a59 | |||
| c04864f693 | |||
| a9b05bba15 | |||
| 6ce19eb4a8 | |||
| 52ddf9b4f7 | |||
| 61a7b5bceb | |||
| 70cfe9bfb0 | |||
| b63f1f01b5 | |||
| e9d9690844 | |||
| b1a31d4d68 | |||
| 5cb33b2858 | |||
| 679f394b51 | |||
| 91b5b06a7f |
@@ -34,7 +34,10 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
android:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
# Gated on a repository variable so the release can ship iOS alone while the
|
||||
# Google Play account and its signing secrets are not yet in place. Set the
|
||||
# repository variable NATIVE_RELEASE_ANDROID to 1 to turn the Android half on.
|
||||
if: github.repository == 'liketrek/TREK' && vars.NATIVE_RELEASE_ANDROID == '1'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 40
|
||||
environment: store
|
||||
@@ -148,44 +151,31 @@ jobs:
|
||||
ASC_KEY_P8_B64: ${{ secrets.ASC_KEY_P8_B64 }}
|
||||
run: printf '%s' "$ASC_KEY_P8_B64" | base64 -d > "$RUNNER_TEMP/AuthKey.p8"
|
||||
|
||||
- name: Archive
|
||||
working-directory: client/ios/App
|
||||
- name: Distribution certificate
|
||||
env:
|
||||
APPLE_DIST_CERT_P12_B64: ${{ secrets.APPLE_DIST_CERT_P12_B64 }}
|
||||
run: printf '%s' "$APPLE_DIST_CERT_P12_B64" | base64 -d > "$RUNNER_TEMP/dist.p12"
|
||||
|
||||
# Signs for distribution and uploads to TestFlight. Automatic signing cannot
|
||||
# run on a device-less CI runner (it signs for development and then needs a
|
||||
# development profile, which needs a registered device), so client/fastlane/Fastfile
|
||||
# imports the stored distribution certificate and creates the App Store profile
|
||||
# through the API key.
|
||||
- name: Build and upload to TestFlight
|
||||
working-directory: client
|
||||
env:
|
||||
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
||||
ASC_KEY_PATH: ${{ runner.temp }}/AuthKey.p8
|
||||
CERT_P12_PATH: ${{ runner.temp }}/dist.p12
|
||||
CERT_P12_PASSWORD: ${{ secrets.APPLE_DIST_CERT_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
||||
run: >-
|
||||
xcodebuild archive
|
||||
-project App.xcodeproj
|
||||
-scheme App
|
||||
-configuration Release
|
||||
-destination 'generic/platform=iOS'
|
||||
-archivePath "$RUNNER_TEMP/TREK.xcarchive"
|
||||
MARKETING_VERSION=${{ steps.version.outputs.version }}
|
||||
CURRENT_PROJECT_VERSION=${{ steps.version.outputs.build }}
|
||||
DEVELOPMENT_TEAM="$APPLE_TEAM_ID"
|
||||
-allowProvisioningUpdates
|
||||
-authenticationKeyPath "$RUNNER_TEMP/AuthKey.p8"
|
||||
-authenticationKeyID "$ASC_KEY_ID"
|
||||
-authenticationKeyIssuerID "$ASC_ISSUER_ID"
|
||||
MARKETING_VERSION: ${{ steps.version.outputs.version }}
|
||||
CURRENT_PROJECT_VERSION: ${{ steps.version.outputs.build }}
|
||||
FASTLANE_SKIP_UPDATE_CHECK: '1'
|
||||
FASTLANE_HIDE_CHANGELOG: '1'
|
||||
run: fastlane ios beta
|
||||
|
||||
# ExportOptions.plist says destination=upload, so this step sends the build
|
||||
# to App Store Connect, where it shows up in TestFlight after processing.
|
||||
- name: Export and upload to App Store Connect
|
||||
working-directory: client/ios/App
|
||||
env:
|
||||
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
||||
run: >-
|
||||
xcodebuild -exportArchive
|
||||
-archivePath "$RUNNER_TEMP/TREK.xcarchive"
|
||||
-exportOptionsPlist ../ExportOptions.plist
|
||||
-exportPath "$RUNNER_TEMP/export"
|
||||
-allowProvisioningUpdates
|
||||
-authenticationKeyPath "$RUNNER_TEMP/AuthKey.p8"
|
||||
-authenticationKeyID "$ASC_KEY_ID"
|
||||
-authenticationKeyIssuerID "$ASC_ISSUER_ID"
|
||||
|
||||
- name: Remove the API key
|
||||
- name: Remove secrets
|
||||
if: always()
|
||||
run: rm -f "$RUNNER_TEMP/AuthKey.p8"
|
||||
run: rm -f "$RUNNER_TEMP/AuthKey.p8" "$RUNNER_TEMP/dist.p12"
|
||||
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
# unsigned: the upload key only exists in native-release.yml.
|
||||
- name: Assemble debug and release APKs
|
||||
working-directory: client/android
|
||||
run: ./gradlew assembleDebug assembleRelease lintDebug --no-daemon
|
||||
run: ./gradlew assembleDebug assembleRelease lintDebug testDebugUnitTest --no-daemon
|
||||
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
@@ -103,7 +103,7 @@ jobs:
|
||||
xcodebuild -version
|
||||
|
||||
- name: Check the property lists
|
||||
run: plutil -lint client/ios/App/App/Info.plist client/ios/ExportOptions.plist
|
||||
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
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
|
||||
@@ -56,6 +56,10 @@ dependencies {
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation "androidx.browser:browser:$androidxBrowserVersion"
|
||||
implementation "androidx.biometric:biometric:1.1.0"
|
||||
// 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"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
|
||||
@@ -23,6 +23,16 @@
|
||||
<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.
|
||||
|
||||
@@ -50,6 +60,15 @@
|
||||
|
||||
</activity>
|
||||
|
||||
<!-- Push: the relay's data messages, decrypted and shown here. -->
|
||||
<service
|
||||
android:name=".TrekPushService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
@@ -76,5 +95,7 @@
|
||||
Play hide the app from tablets and other GPS-less devices. -->
|
||||
<uses-feature android:name="android.hardware.location.gps" android:required="false" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<!-- Android 13 and later ask before an app may show notifications. -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
</manifest>
|
||||
|
||||
@@ -26,6 +26,13 @@ public class MainActivity extends BridgeActivity {
|
||||
bridge.getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER);
|
||||
// Set before any WebView callback can run: they are posted to this thread.
|
||||
if (onServer) bridge.setWebViewClient(new TrekWebViewClient(bridge, this::showAddressScreen));
|
||||
TrekSharedFiles.receive(this, getIntent());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
TrekSharedFiles.receive(this, intent);
|
||||
}
|
||||
|
||||
private boolean leavingServer;
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package com.liketrek.trek;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.security.keystore.KeyGenParameterSpec;
|
||||
import android.security.keystore.KeyProperties;
|
||||
import android.util.Base64;
|
||||
import java.math.BigInteger;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.KeyStore;
|
||||
import java.security.interfaces.ECPrivateKey;
|
||||
import java.security.interfaces.ECPublicKey;
|
||||
import java.security.spec.ECGenParameterSpec;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.security.SecureRandom;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.KeyGenerator;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
|
||||
/**
|
||||
* The device's Web Push key pair (P-256) and auth secret (RFC 8291). The server
|
||||
* encrypts every notification for these, and TrekPushService decrypts with
|
||||
* them. They are kept in the app's preferences, sealed with an AES key that
|
||||
* never leaves the Android Keystore, so a copy of the preferences alone opens
|
||||
* nothing.
|
||||
*/
|
||||
final class TrekPushKeys {
|
||||
|
||||
private static final String PREFS = "trek_push";
|
||||
private static final String WRAP_ALIAS = "trek_push_wrap";
|
||||
private static final String PRIVATE = "private";
|
||||
private static final String PUBLIC = "public";
|
||||
private static final String AUTH = "auth";
|
||||
|
||||
final ECPrivateKey privateKey;
|
||||
/** Uncompressed point, 65 bytes, as the server reads p256dh. */
|
||||
final byte[] publicKey;
|
||||
final byte[] auth;
|
||||
|
||||
TrekPushKeys(ECPrivateKey privateKey, byte[] publicKey, byte[] auth) {
|
||||
this.privateKey = privateKey;
|
||||
this.publicKey = publicKey;
|
||||
this.auth = auth;
|
||||
}
|
||||
|
||||
/** The stored keys, or null when the app never subscribed. */
|
||||
static synchronized TrekPushKeys load(Context context) throws Exception {
|
||||
SharedPreferences prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||
String sealedPrivate = prefs.getString(PRIVATE, null);
|
||||
String publicKey = prefs.getString(PUBLIC, null);
|
||||
String sealedAuth = prefs.getString(AUTH, null);
|
||||
if (sealedPrivate == null || publicKey == null || sealedAuth == null) return null;
|
||||
byte[] pkcs8 = unseal(sealedPrivate);
|
||||
ECPrivateKey key = (ECPrivateKey) KeyFactory.getInstance("EC").generatePrivate(new PKCS8EncodedKeySpec(pkcs8));
|
||||
return new TrekPushKeys(key, Base64.decode(publicKey, Base64.NO_WRAP), unseal(sealedAuth));
|
||||
}
|
||||
|
||||
static synchronized TrekPushKeys loadOrCreate(Context context) throws Exception {
|
||||
TrekPushKeys existing;
|
||||
try {
|
||||
existing = load(context);
|
||||
} catch (Exception unreadable) {
|
||||
// The Keystore key is gone (backup restored to another device, data
|
||||
// cleared): start over, the next subscription carries the new keys.
|
||||
existing = null;
|
||||
}
|
||||
if (existing != null) return existing;
|
||||
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
|
||||
generator.initialize(new ECGenParameterSpec("secp256r1"));
|
||||
KeyPair pair = generator.generateKeyPair();
|
||||
byte[] publicKey = uncompressed((ECPublicKey) pair.getPublic());
|
||||
byte[] auth = new byte[16];
|
||||
new SecureRandom().nextBytes(auth);
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(PRIVATE, seal(pair.getPrivate().getEncoded()))
|
||||
.putString(PUBLIC, Base64.encodeToString(publicKey, Base64.NO_WRAP))
|
||||
.putString(AUTH, seal(auth))
|
||||
.apply();
|
||||
return new TrekPushKeys((ECPrivateKey) pair.getPrivate(), publicKey, auth);
|
||||
}
|
||||
|
||||
static String base64url(byte[] data) {
|
||||
return Base64.encodeToString(data, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING);
|
||||
}
|
||||
|
||||
static byte[] uncompressed(ECPublicKey key) {
|
||||
byte[] out = new byte[65];
|
||||
out[0] = 0x04;
|
||||
copyFixed(key.getW().getAffineX(), out, 1);
|
||||
copyFixed(key.getW().getAffineY(), out, 33);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A coordinate as exactly 32 bytes: BigInteger adds a sign byte or drops leading zeros. */
|
||||
private static void copyFixed(BigInteger value, byte[] out, int offset) {
|
||||
byte[] raw = value.toByteArray();
|
||||
int start = Math.max(0, raw.length - 32);
|
||||
int length = raw.length - start;
|
||||
System.arraycopy(raw, start, out, offset + 32 - length, length);
|
||||
}
|
||||
|
||||
private static SecretKey wrapKey() throws Exception {
|
||||
KeyStore store = KeyStore.getInstance("AndroidKeyStore");
|
||||
store.load(null);
|
||||
if (store.containsAlias(WRAP_ALIAS)) return ((KeyStore.SecretKeyEntry) store.getEntry(WRAP_ALIAS, null)).getSecretKey();
|
||||
KeyGenerator generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore");
|
||||
generator.init(
|
||||
new KeyGenParameterSpec.Builder(WRAP_ALIAS, KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setKeySize(256)
|
||||
.build()
|
||||
);
|
||||
return generator.generateKey();
|
||||
}
|
||||
|
||||
private static String seal(byte[] plain) throws Exception {
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, wrapKey());
|
||||
byte[] iv = cipher.getIV();
|
||||
byte[] sealed = cipher.doFinal(plain);
|
||||
byte[] out = new byte[iv.length + sealed.length];
|
||||
System.arraycopy(iv, 0, out, 0, iv.length);
|
||||
System.arraycopy(sealed, 0, out, iv.length, sealed.length);
|
||||
return Base64.encodeToString(out, Base64.NO_WRAP);
|
||||
}
|
||||
|
||||
private static byte[] unseal(String value) throws Exception {
|
||||
byte[] in = Base64.decode(value, Base64.NO_WRAP);
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, wrapKey(), new GCMParameterSpec(128, in, 0, 12));
|
||||
return cipher.doFinal(in, 12, in.length - 12);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.liketrek.trek;
|
||||
|
||||
import android.Manifest;
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
import android.util.Base64;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import com.google.firebase.messaging.FirebaseMessagingService;
|
||||
import com.google.firebase.messaging.RemoteMessage;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import org.json.JSONObject;
|
||||
|
||||
/**
|
||||
* Receives the relay's data messages ({enc}: the server's encrypted Web Push
|
||||
* record), decrypts them with the device's keys and shows the notification.
|
||||
* Without the keys, or when the record does not open, it shows the same
|
||||
* neutral text the iOS extension falls back to, never the ciphertext.
|
||||
*/
|
||||
public class TrekPushService extends FirebaseMessagingService {
|
||||
|
||||
static final String CHANNEL = "trek";
|
||||
static final String EXTRA_URL = "trek.pushUrl";
|
||||
|
||||
@Override
|
||||
public void onMessageReceived(@NonNull RemoteMessage message) {
|
||||
String enc = message.getData().get("enc");
|
||||
if (enc == null) return;
|
||||
String title = getString(R.string.push_fallback_title);
|
||||
String body = getString(R.string.push_fallback_body);
|
||||
String url = null;
|
||||
String tag = null;
|
||||
try {
|
||||
TrekPushKeys keys = TrekPushKeys.load(this);
|
||||
if (keys != null) {
|
||||
byte[] plain = TrekWebPush.decrypt(Base64.decode(enc, Base64.DEFAULT), keys);
|
||||
JSONObject payload = new JSONObject(new String(plain, StandardCharsets.UTF_8));
|
||||
if (!payload.optString("title").isEmpty()) title = payload.optString("title");
|
||||
body = payload.optString("body", body);
|
||||
url = payload.optString("url", null);
|
||||
tag = payload.optString("tag", null);
|
||||
}
|
||||
} catch (Exception unreadable) {
|
||||
// Shown with the neutral text: the tap still opens the app.
|
||||
}
|
||||
show(this, title, body, url, tag);
|
||||
}
|
||||
|
||||
/** A new token means a new endpoint; the UI re-registers on its next start (resyncPushSubscription). */
|
||||
@Override
|
||||
public void onNewToken(@NonNull String token) {}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private static void show(Context context, String title, String body, String url, String tag) {
|
||||
ensureChannel(context);
|
||||
Intent open = new Intent(context, MainActivity.class)
|
||||
.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||
if (url != null) open.putExtra(EXTRA_URL, url);
|
||||
int id = tag != null ? tag.hashCode() : (int) (System.currentTimeMillis() & 0x7fffffff);
|
||||
PendingIntent tap = PendingIntent.getActivity(
|
||||
context,
|
||||
id,
|
||||
open,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
|
||||
);
|
||||
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)
|
||||
.setContentIntent(tap);
|
||||
if (Build.VERSION.SDK_INT >= 33
|
||||
&& ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
NotificationManagerCompat.from(context).notify(tag, id, builder.build());
|
||||
} catch (SecurityException notAllowed) {
|
||||
// Notifications switched off for the app since it subscribed.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.liketrek.trek;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.provider.OpenableColumns;
|
||||
import android.util.Base64;
|
||||
import com.getcapacitor.JSObject;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
|
||||
/**
|
||||
* Booking documents shared to TREK (open with / share), held until the UI takes
|
||||
* them (TrekShell.takeSharedFile) and announced if it is already running.
|
||||
*/
|
||||
final class TrekSharedFiles {
|
||||
/** Above this the file is not a booking confirmation. */
|
||||
private static final int MAX_BYTES = 15 * 1024 * 1024;
|
||||
|
||||
interface Listener {
|
||||
void onFile(JSObject file);
|
||||
}
|
||||
|
||||
private static JSObject pending;
|
||||
private static Listener listener;
|
||||
|
||||
private TrekSharedFiles() {}
|
||||
|
||||
static void setListener(Listener l) {
|
||||
listener = l;
|
||||
}
|
||||
|
||||
static JSObject take() {
|
||||
JSObject file = pending;
|
||||
pending = null;
|
||||
return file;
|
||||
}
|
||||
|
||||
static void receive(Context context, Intent intent) {
|
||||
if (intent == null) return;
|
||||
Uri uri = null;
|
||||
if (Intent.ACTION_VIEW.equals(intent.getAction())) uri = intent.getData();
|
||||
else if (Intent.ACTION_SEND.equals(intent.getAction())) uri = intent.getParcelableExtra(Intent.EXTRA_STREAM);
|
||||
if (uri == null) return;
|
||||
try (InputStream in = context.getContentResolver().openInputStream(uri)) {
|
||||
if (in == null) return;
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
byte[] buffer = new byte[16384];
|
||||
int read;
|
||||
while ((read = in.read(buffer)) != -1) {
|
||||
out.write(buffer, 0, read);
|
||||
if (out.size() > MAX_BYTES) return;
|
||||
}
|
||||
JSObject file = new JSObject();
|
||||
file.put("name", displayName(context, uri));
|
||||
String type = context.getContentResolver().getType(uri);
|
||||
file.put("mimeType", type == null ? intent.getType() : type);
|
||||
file.put("data", Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP));
|
||||
pending = file;
|
||||
if (listener != null) listener.onFile(file);
|
||||
} catch (Exception ignored) {
|
||||
// Unreadable or gone: nothing to import.
|
||||
}
|
||||
}
|
||||
|
||||
private static String displayName(Context context, Uri uri) {
|
||||
try (Cursor cursor = context.getContentResolver().query(uri, new String[] { OpenableColumns.DISPLAY_NAME }, null, null, null)) {
|
||||
if (cursor != null && cursor.moveToFirst()) return cursor.getString(0);
|
||||
} catch (Exception ignored) {
|
||||
// Fall through to the path.
|
||||
}
|
||||
String last = uri.getLastPathSegment();
|
||||
return last == null ? "booking" : last;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,29 @@
|
||||
package com.liketrek.trek;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import androidx.browser.customtabs.CustomTabsIntent;
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.PermissionState;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
import com.getcapacitor.annotation.Permission;
|
||||
import com.getcapacitor.annotation.PermissionCallback;
|
||||
import com.google.firebase.messaging.FirebaseMessaging;
|
||||
import org.json.JSONObject;
|
||||
|
||||
/** The native half of client/src/native/trekShell.ts. */
|
||||
@CapacitorPlugin(name = "TrekShell")
|
||||
@CapacitorPlugin(
|
||||
name = "TrekShell",
|
||||
permissions = { @Permission(strings = { Manifest.permission.POST_NOTIFICATIONS }, alias = "notifications") }
|
||||
)
|
||||
public class TrekShellPlugin extends Plugin {
|
||||
|
||||
/** How long to wait after returning from the browser for the redirect to land. */
|
||||
@@ -24,6 +34,190 @@ public class TrekShellPlugin extends Plugin {
|
||||
private boolean authBrowserOpen;
|
||||
private final Handler handler = new Handler(Looper.getMainLooper());
|
||||
|
||||
/**
|
||||
* What this binary offers the server's UI, raised with every native feature
|
||||
* an older binary lacks. The UI asks before it calls (native/capabilities.ts),
|
||||
* and a server can require a minimum (minNativeShellApi in /api/health/features).
|
||||
*/
|
||||
static final int SHELL_API = 2;
|
||||
/** The native features by name, so the UI can turn each on by itself. */
|
||||
static final String[] FEATURES = { "capabilities", "biometricLock", "sharedFiles", "nativePush" };
|
||||
|
||||
/** liketrek's push relay: the endpoint for this device's FCM token (trek-hosted push-relay.ts). */
|
||||
static final String PUSH_RELAY = "https://push.liketrek.com/v1/fcm/";
|
||||
|
||||
/** The notification the app was started from, until the UI asks for it. */
|
||||
private String openedPushUrl;
|
||||
|
||||
@PluginMethod
|
||||
public void getCapabilities(PluginCall call) {
|
||||
JSObject result = new JSObject();
|
||||
result.put("shellApi", SHELL_API);
|
||||
result.put("platform", "android");
|
||||
String version = "";
|
||||
String build = "";
|
||||
try {
|
||||
android.content.pm.PackageInfo info = getContext().getPackageManager().getPackageInfo(getContext().getPackageName(), 0);
|
||||
version = info.versionName == null ? "" : info.versionName;
|
||||
build = String.valueOf(androidx.core.content.pm.PackageInfoCompat.getLongVersionCode(info));
|
||||
} catch (android.content.pm.PackageManager.NameNotFoundException ignored) {
|
||||
// Our own package is always there; the fields simply stay empty.
|
||||
}
|
||||
result.put("appVersion", version);
|
||||
result.put("build", build);
|
||||
com.getcapacitor.JSArray features = new com.getcapacitor.JSArray();
|
||||
for (String feature : FEATURES) features.put(feature);
|
||||
result.put("features", features);
|
||||
call.resolve(result);
|
||||
}
|
||||
|
||||
// ---- Shared booking documents ----
|
||||
|
||||
@Override
|
||||
public void load() {
|
||||
TrekSharedFiles.setListener(file -> notifyListeners("sharedFile", file));
|
||||
if (getActivity() != null && getActivity().getIntent() != null) {
|
||||
openedPushUrl = getActivity().getIntent().getStringExtra(TrekPushService.EXTRA_URL);
|
||||
getActivity().getIntent().removeExtra(TrekPushService.EXTRA_URL);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Push ----
|
||||
|
||||
/**
|
||||
* Permission, the FCM token and the device keys, as the Web Push subscription
|
||||
* the server stores (native/push.ts). Rejects with DENIED when the user
|
||||
* refuses notifications, as on iOS.
|
||||
*/
|
||||
@PluginMethod
|
||||
public void registerPush(PluginCall call) {
|
||||
if (Build.VERSION.SDK_INT >= 33 && getPermissionState("notifications") != PermissionState.GRANTED) {
|
||||
requestPermissionForAlias("notifications", call, "pushPermissionAnswered");
|
||||
return;
|
||||
}
|
||||
subscribePush(call);
|
||||
}
|
||||
|
||||
@PermissionCallback
|
||||
private void pushPermissionAnswered(PluginCall call) {
|
||||
if (getPermissionState("notifications") != PermissionState.GRANTED) {
|
||||
call.reject("Notifications are not allowed", "DENIED");
|
||||
return;
|
||||
}
|
||||
subscribePush(call);
|
||||
}
|
||||
|
||||
private void subscribePush(PluginCall call) {
|
||||
if (!NotificationManagerCompat.from(getContext()).areNotificationsEnabled()) {
|
||||
call.reject("Notifications are not allowed", "DENIED");
|
||||
return;
|
||||
}
|
||||
TrekPushService.ensureChannel(getContext());
|
||||
final TrekPushKeys keys;
|
||||
try {
|
||||
keys = TrekPushKeys.loadOrCreate(getContext());
|
||||
} catch (Exception e) {
|
||||
call.reject("Could not create the push keys", e);
|
||||
return;
|
||||
}
|
||||
FirebaseMessaging.getInstance().getToken().addOnCompleteListener(task -> {
|
||||
if (!task.isSuccessful() || task.getResult() == null) {
|
||||
call.reject("Could not get a push token", task.getException());
|
||||
return;
|
||||
}
|
||||
JSObject subscription = new JSObject();
|
||||
subscription.put("endpoint", PUSH_RELAY + task.getResult());
|
||||
JSObject keyObject = new JSObject();
|
||||
keyObject.put("p256dh", TrekPushKeys.base64url(keys.publicKey));
|
||||
keyObject.put("auth", TrekPushKeys.base64url(keys.auth));
|
||||
subscription.put("keys", keyObject);
|
||||
call.resolve(subscription);
|
||||
});
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void takeOpenedPush(PluginCall call) {
|
||||
JSObject result = new JSObject();
|
||||
result.put("url", openedPushUrl == null ? JSONObject.NULL : openedPushUrl);
|
||||
openedPushUrl = null;
|
||||
call.resolve(result);
|
||||
}
|
||||
|
||||
/** The document shared to TREK before the UI was there to hear of it. */
|
||||
@PluginMethod
|
||||
public void takeSharedFile(PluginCall call) {
|
||||
JSObject result = new JSObject();
|
||||
JSObject file = TrekSharedFiles.take();
|
||||
result.put("file", file == null ? JSONObject.NULL : file);
|
||||
call.resolve(result);
|
||||
}
|
||||
|
||||
// ---- App lock ----
|
||||
|
||||
private static final int AUTHENTICATORS =
|
||||
androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK
|
||||
| androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL;
|
||||
|
||||
/** Which biometry the device offers for the app lock, if any. */
|
||||
@PluginMethod
|
||||
public void biometryInfo(PluginCall call) {
|
||||
int status = androidx.biometric.BiometricManager.from(getContext()).canAuthenticate(AUTHENTICATORS);
|
||||
JSObject result = new JSObject();
|
||||
result.put("available", status == androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS);
|
||||
result.put("type", "biometric");
|
||||
call.resolve(result);
|
||||
}
|
||||
|
||||
/** The system biometric prompt with the screen lock as the way out. */
|
||||
@PluginMethod
|
||||
public void authenticateBiometric(PluginCall call) {
|
||||
String reason = call.getString("reason", "Unlock TREK");
|
||||
getActivity().runOnUiThread(() -> {
|
||||
androidx.fragment.app.FragmentActivity activity = (androidx.fragment.app.FragmentActivity) getActivity();
|
||||
androidx.biometric.BiometricPrompt prompt = new androidx.biometric.BiometricPrompt(
|
||||
activity,
|
||||
androidx.core.content.ContextCompat.getMainExecutor(getContext()),
|
||||
new androidx.biometric.BiometricPrompt.AuthenticationCallback() {
|
||||
@Override
|
||||
public void onAuthenticationSucceeded(androidx.biometric.BiometricPrompt.AuthenticationResult result) {
|
||||
JSObject ok = new JSObject();
|
||||
ok.put("ok", true);
|
||||
call.resolve(ok);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAuthenticationError(int code, CharSequence message) {
|
||||
boolean cancelled = code == androidx.biometric.BiometricPrompt.ERROR_USER_CANCELED
|
||||
|| code == androidx.biometric.BiometricPrompt.ERROR_NEGATIVE_BUTTON
|
||||
|| code == androidx.biometric.BiometricPrompt.ERROR_CANCELED;
|
||||
call.reject(message.toString(), cancelled ? "CANCELLED" : "FAILED");
|
||||
}
|
||||
}
|
||||
);
|
||||
androidx.biometric.BiometricPrompt.PromptInfo info = new androidx.biometric.BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(reason)
|
||||
.setAllowedAuthenticators(AUTHENTICATORS)
|
||||
.build();
|
||||
prompt.authenticate(info);
|
||||
});
|
||||
}
|
||||
|
||||
/** While on, the recent-apps screen shows no snapshot of the app. */
|
||||
@PluginMethod
|
||||
public void setPrivacyCover(PluginCall call) {
|
||||
boolean enabled = Boolean.TRUE.equals(call.getBoolean("enabled", false));
|
||||
getActivity().runOnUiThread(() -> {
|
||||
if (android.os.Build.VERSION.SDK_INT >= 33) {
|
||||
getActivity().setRecentsScreenshotEnabled(!enabled);
|
||||
} else if (enabled) {
|
||||
getActivity().getWindow().addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE);
|
||||
} else {
|
||||
getActivity().getWindow().clearFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE);
|
||||
}
|
||||
});
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void getServer(PluginCall call) {
|
||||
String url = TrekServerStore.getUrl(getContext());
|
||||
@@ -87,6 +281,14 @@ public class TrekShellPlugin extends Plugin {
|
||||
@Override
|
||||
protected void handleOnNewIntent(Intent intent) {
|
||||
super.handleOnNewIntent(intent);
|
||||
String pushUrl = intent.getStringExtra(TrekPushService.EXTRA_URL);
|
||||
if (pushUrl != null) {
|
||||
intent.removeExtra(TrekPushService.EXTRA_URL);
|
||||
JSObject opened = new JSObject();
|
||||
opened.put("url", pushUrl);
|
||||
notifyListeners("pushOpened", opened);
|
||||
return;
|
||||
}
|
||||
Uri data = intent.getData();
|
||||
PluginCall call = pendingAuthCall();
|
||||
if (call == null || data == null || !authScheme.equals(data.getScheme())) return;
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.liketrek.trek;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.interfaces.ECPrivateKey;
|
||||
import java.security.interfaces.ECPublicKey;
|
||||
import java.security.spec.ECPoint;
|
||||
import java.security.spec.ECPublicKeySpec;
|
||||
import java.util.Arrays;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.KeyAgreement;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
/**
|
||||
* Decrypts a Web Push record (RFC 8291, aes128gcm content coding of RFC 8188):
|
||||
* the same thing a browser does, and the counterpart of the iOS notification
|
||||
* extension's WebPushRecord. One record, as TREK's server always sends.
|
||||
*/
|
||||
final class TrekWebPush {
|
||||
|
||||
private TrekWebPush() {}
|
||||
|
||||
static byte[] decrypt(byte[] record, TrekPushKeys keys) throws Exception {
|
||||
if (record.length < 21) throw new IllegalArgumentException("record too short");
|
||||
byte[] salt = Arrays.copyOfRange(record, 0, 16);
|
||||
int idLength = record[20] & 0xff;
|
||||
if (idLength != 65 || record.length < 21 + idLength + 17) throw new IllegalArgumentException("unexpected header");
|
||||
byte[] senderKey = Arrays.copyOfRange(record, 21, 21 + idLength);
|
||||
byte[] ciphertext = Arrays.copyOfRange(record, 21 + idLength, record.length);
|
||||
|
||||
KeyAgreement agreement = KeyAgreement.getInstance("ECDH");
|
||||
agreement.init(keys.privateKey);
|
||||
agreement.doPhase(publicKeyFrom(senderKey, keys.privateKey), true);
|
||||
byte[] shared = agreement.generateSecret();
|
||||
|
||||
byte[] keyInfo = concat("WebPush: info\0".getBytes(StandardCharsets.US_ASCII), keys.publicKey, senderKey);
|
||||
byte[] ikm = hkdf(keys.auth, shared, keyInfo, 32);
|
||||
byte[] cek = hkdf(salt, ikm, "Content-Encoding: aes128gcm\0".getBytes(StandardCharsets.US_ASCII), 16);
|
||||
byte[] nonce = hkdf(salt, ikm, "Content-Encoding: nonce\0".getBytes(StandardCharsets.US_ASCII), 12);
|
||||
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(cek, "AES"), new GCMParameterSpec(128, nonce));
|
||||
byte[] padded = cipher.doFinal(ciphertext);
|
||||
|
||||
// Padding: the content, one delimiter (2 for the last record), then zeros.
|
||||
int end = padded.length - 1;
|
||||
while (end >= 0 && padded[end] == 0) end--;
|
||||
if (end < 0 || padded[end] != 2) throw new IllegalArgumentException("bad padding");
|
||||
return Arrays.copyOf(padded, end);
|
||||
}
|
||||
|
||||
private static ECPublicKey publicKeyFrom(byte[] point, ECPrivateKey onCurve) throws Exception {
|
||||
if (point[0] != 0x04) throw new IllegalArgumentException("compressed key");
|
||||
BigInteger x = new BigInteger(1, Arrays.copyOfRange(point, 1, 33));
|
||||
BigInteger y = new BigInteger(1, Arrays.copyOfRange(point, 33, 65));
|
||||
return (ECPublicKey) KeyFactory.getInstance("EC").generatePublic(new ECPublicKeySpec(new ECPoint(x, y), onCurve.getParams()));
|
||||
}
|
||||
|
||||
/** HKDF-SHA256 (RFC 5869) for outputs of at most one block. */
|
||||
private static byte[] hkdf(byte[] salt, byte[] ikm, byte[] info, int length) throws Exception {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(salt, "HmacSHA256"));
|
||||
byte[] prk = mac.doFinal(ikm);
|
||||
mac.init(new SecretKeySpec(prk, "HmacSHA256"));
|
||||
mac.update(info);
|
||||
mac.update((byte) 1);
|
||||
return Arrays.copyOf(mac.doFinal(), length);
|
||||
}
|
||||
|
||||
private static byte[] concat(byte[]... parts) {
|
||||
int length = 0;
|
||||
for (byte[] part : parts) length += part.length;
|
||||
byte[] out = new byte[length];
|
||||
int offset = 0;
|
||||
for (byte[] part : parts) {
|
||||
System.arraycopy(part, 0, out, offset, part.length);
|
||||
offset += part.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Status bar icon for notifications: a single-colour map pin, as Android
|
||||
tints it and shows only its shape. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,2C8.13,2 5,5.13 5,9c0,5.25 7,13 7,13s7,-7.75 7,-13c0,-3.87 -3.13,-7 -7,-7zM12,11.5c-1.38,0 -2.5,-1.12 -2.5,-2.5s1.12,-2.5 2.5,-2.5 2.5,1.12 2.5,2.5 -1.12,2.5 -2.5,2.5z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The Firebase project the app receives push through (liketrek push relay).
|
||||
These are the strings the google-services Gradle plugin would generate from
|
||||
google-services.json; Firebase reads them at start. None of them is a secret:
|
||||
they identify the project, sending needs the relay's service account. -->
|
||||
<resources>
|
||||
<string name="google_app_id" translatable="false">1:1074454522965:android:3ff2fe270a27a73051b8dc</string>
|
||||
<string name="gcm_defaultSenderId" translatable="false">1074454522965</string>
|
||||
<string name="google_api_key" translatable="false">AIzaSyB2VYXmZ90BZuksHhHR_n2W8naNE_vJAJA</string>
|
||||
<string name="google_crash_reporting_api_key" translatable="false">AIzaSyB2VYXmZ90BZuksHhHR_n2W8naNE_vJAJA</string>
|
||||
<string name="project_id" translatable="false">trek-9392e</string>
|
||||
<string name="google_storage_bucket" translatable="false">trek-9392e.firebasestorage.app</string>
|
||||
</resources>
|
||||
@@ -4,4 +4,7 @@
|
||||
<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>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.liketrek.trek;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.interfaces.ECPrivateKey;
|
||||
import java.security.interfaces.ECPublicKey;
|
||||
import java.security.spec.ECGenParameterSpec;
|
||||
import java.security.spec.ECPrivateKeySpec;
|
||||
import java.util.Base64;
|
||||
import org.junit.Test;
|
||||
|
||||
/** The decryption against the worked example in RFC 8291, section 5. */
|
||||
public class TrekWebPushTest {
|
||||
|
||||
private static byte[] b64(String value) {
|
||||
return Base64.getUrlDecoder().decode(value);
|
||||
}
|
||||
|
||||
private static TrekPushKeys rfcKeys() throws Exception {
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
|
||||
generator.initialize(new ECGenParameterSpec("secp256r1"));
|
||||
ECPublicKey anyKey = (ECPublicKey) generator.generateKeyPair().getPublic();
|
||||
BigInteger d = new BigInteger(1, b64("q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94"));
|
||||
ECPrivateKey privateKey = (ECPrivateKey) KeyFactory.getInstance("EC").generatePrivate(new ECPrivateKeySpec(d, anyKey.getParams()));
|
||||
return new TrekPushKeys(
|
||||
privateKey,
|
||||
b64("BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"),
|
||||
b64("BTBZMqHH6r4Tts7J_aSIgg")
|
||||
);
|
||||
}
|
||||
|
||||
private static final String RECORD =
|
||||
"DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPTpK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN";
|
||||
|
||||
@Test
|
||||
public void decryptsTheRfcExample() throws Exception {
|
||||
byte[] plain = TrekWebPush.decrypt(b64(RECORD), rfcKeys());
|
||||
assertEquals("When I grow up, I want to be a watermelon", new String(plain, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void refusesATamperedRecord() throws Exception {
|
||||
byte[] record = b64(RECORD);
|
||||
record[record.length - 1] ^= 1;
|
||||
assertThrows(Exception.class, () -> TrekWebPush.decrypt(record, rfcKeys()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void writesThePublicKeyAsAFixedLengthPoint() throws Exception {
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
|
||||
generator.initialize(new ECGenParameterSpec("secp256r1"));
|
||||
for (int i = 0; i < 20; i++) {
|
||||
byte[] point = TrekPushKeys.uncompressed((ECPublicKey) generator.generateKeyPair().getPublic());
|
||||
assertEquals(65, point.length);
|
||||
assertEquals(4, point[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
# iOS release signing and TestFlight upload, driven by .github/workflows/native-release.yml.
|
||||
#
|
||||
# Automatic signing cannot work on a CI runner with no registered devices: it signs
|
||||
# the archive for development and then needs a development provisioning profile, which
|
||||
# requires a device. So this signs manually for distribution instead. The distribution
|
||||
# certificate and the App Store provisioning profile are created (or reused) through the
|
||||
# App Store Connect API key, which means no certificate or profile has to be stored as a
|
||||
# secret: the key the workflow already holds is enough.
|
||||
#
|
||||
# The lane expects these in the environment (the workflow sets them):
|
||||
# ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH — the App Store Connect API key
|
||||
# APPLE_TEAM_ID — the developer team
|
||||
# MARKETING_VERSION, CURRENT_PROJECT_VERSION — the version and build number
|
||||
# RUNNER_TEMP — a writable scratch directory
|
||||
|
||||
default_platform(:ios)
|
||||
|
||||
APP_IDENTIFIER = "com.liketrek.trek"
|
||||
PROJECT = "ios/App/App.xcodeproj"
|
||||
|
||||
# Every signed target with its bundle identifier: the app and its extensions
|
||||
# (ios/scripts/add-extension.rb put them in the project). Each gets its own App
|
||||
# Store profile, created or reused through the API key like the app's.
|
||||
SIGNED_TARGETS = {
|
||||
"App" => APP_IDENTIFIER,
|
||||
"NotificationService" => "com.liketrek.trek.notifications",
|
||||
"TREKWidgets" => "com.liketrek.trek.widgets",
|
||||
}
|
||||
|
||||
platform :ios do
|
||||
desc "Build the signed Release app and upload it to TestFlight (internal testing)."
|
||||
lane :beta do
|
||||
key = app_store_connect_api_key(
|
||||
key_id: ENV.fetch("ASC_KEY_ID"),
|
||||
issuer_id: ENV.fetch("ASC_ISSUER_ID"),
|
||||
key_filepath: ENV.fetch("ASC_KEY_PATH"),
|
||||
in_house: false,
|
||||
)
|
||||
|
||||
# A throwaway keychain with a known password, so fastlane can unlock the imported
|
||||
# private key for code signing (without the password it cannot set the key's access
|
||||
# list and the certificate is never usable).
|
||||
keychain_name = "trek_ci.keychain"
|
||||
keychain_password = "trek-ci-#{ENV.fetch('GITHUB_RUN_ID', 'local')}"
|
||||
create_keychain(
|
||||
name: keychain_name,
|
||||
password: keychain_password,
|
||||
default_keychain: true,
|
||||
unlock: true,
|
||||
timeout: 3600,
|
||||
lock_when_sleeps: false,
|
||||
)
|
||||
keychain_path = File.expand_path("~/Library/Keychains/#{keychain_name}-db")
|
||||
|
||||
# Import the one distribution certificate from its stored .p12, rather than
|
||||
# minting a new one each run — Apple caps distribution certificates at two, so
|
||||
# a fresh one per build would quickly hit the limit. The matching App Store
|
||||
# profile is created (or reused) through the API key.
|
||||
import_certificate(
|
||||
certificate_path: ENV.fetch("CERT_P12_PATH"),
|
||||
certificate_password: ENV.fetch("CERT_P12_PASSWORD"),
|
||||
keychain_name: keychain_name,
|
||||
keychain_path: keychain_path,
|
||||
keychain_password: keychain_password,
|
||||
)
|
||||
profiles = {}
|
||||
SIGNED_TARGETS.each do |target, identifier|
|
||||
get_provisioning_profile(
|
||||
api_key: key,
|
||||
app_identifier: identifier,
|
||||
development: false,
|
||||
)
|
||||
profiles[identifier] = lane_context[SharedValues::SIGH_NAME]
|
||||
|
||||
# Pin each target to its profile for this build; automatic signing stays the
|
||||
# default a developer sees when they open the project in Xcode.
|
||||
update_code_signing_settings(
|
||||
path: PROJECT,
|
||||
use_automatic_signing: false,
|
||||
team_id: ENV.fetch("APPLE_TEAM_ID"),
|
||||
code_sign_identity: "Apple Distribution",
|
||||
bundle_identifier: identifier,
|
||||
profile_name: profiles[identifier],
|
||||
targets: [target],
|
||||
)
|
||||
end
|
||||
|
||||
build_app(
|
||||
project: PROJECT,
|
||||
scheme: "App",
|
||||
configuration: "Release",
|
||||
export_method: "app-store",
|
||||
output_directory: File.join(ENV.fetch("RUNNER_TEMP"), "export"),
|
||||
xcargs: [
|
||||
"MARKETING_VERSION=#{ENV.fetch('MARKETING_VERSION')}",
|
||||
"CURRENT_PROJECT_VERSION=#{ENV.fetch('CURRENT_PROJECT_VERSION')}",
|
||||
].join(" "),
|
||||
export_options: {
|
||||
signingStyle: "manual",
|
||||
provisioningProfiles: profiles,
|
||||
},
|
||||
)
|
||||
|
||||
# Upload to App Store Connect; it shows up in TestFlight after Apple finishes processing.
|
||||
# Internal testing needs no App Review, so nothing here submits the app for review.
|
||||
upload_to_testflight(
|
||||
api_key: key,
|
||||
skip_waiting_for_build_processing: true,
|
||||
skip_submission: true,
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -7,7 +7,14 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
01FD437981FC32C2C2C5BDCE /* TREKWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 702D0DF4A7E323CEA94CA03F /* TREKWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
0C2D60A4950D7293E1F282CB /* TripDayAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 337775CD9091673D0ADDF325 /* TripDayAttributes.swift */; };
|
||||
2162CD349AFFDF070C16A58A /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6E901288FC198E4D33BE754F /* SwiftUI.framework */; };
|
||||
2CFE220A73ACB585F3D4CB8F /* UserNotifications.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2EE43D236E661AA975612A4B /* UserNotifications.framework */; };
|
||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
|
||||
32797E66C09CF7579AC323FD /* TREKWidgetsBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C2E47D0B005674C2B4ADA56 /* TREKWidgetsBundle.swift */; };
|
||||
3FB1872A25267A8E88FCBE3C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BC64330AC19625949E8DC05A /* WidgetKit.framework */; };
|
||||
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 */; };
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
|
||||
@@ -15,14 +22,58 @@
|
||||
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 */; };
|
||||
9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */; };
|
||||
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 */; };
|
||||
84EB79E4B677229481809933 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A03A384FE54FAD1ED68E12DC /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */; };
|
||||
A8FDD4DBE3F078BF935CC4B9 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFA8802204810ED580DE46F3 /* NotificationService.swift */; };
|
||||
B4C0FC386F71D849E141C91E /* NextTripWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7064A9DA92FCC8ACAA4F22A /* NextTripWidget.swift */; };
|
||||
B65B7CFC62EEFF443491EA64 /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 192E69C7E66646BC39566925 /* ActivityKit.framework */; };
|
||||
DF633789F2F667C4CF264C9E /* TripDayLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3259F6C40216CECEBAC6F307 /* TripDayLiveActivity.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
3BB8A38B2B2C8614B24CF2AD /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 9480DFBC2C2A112973DA468A;
|
||||
remoteInfo = NotificationService;
|
||||
};
|
||||
B8BBE7F7A64678D780A7F3D1 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = A3BE5653156025C97CA62C68;
|
||||
remoteInfo = TREKWidgets;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
A8F12B42B22FCA041C6AA0DA /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
84EB79E4B677229481809933 /* NotificationService.appex in Embed Foundation Extensions */,
|
||||
01FD437981FC32C2C2C5BDCE /* TREKWidgets.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
192E69C7E66646BC39566925 /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; };
|
||||
1E7522315D47F086145DC4B8 /* 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>"; };
|
||||
3259F6C40216CECEBAC6F307 /* TripDayLiveActivity.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripDayLiveActivity.swift; sourceTree = "<group>"; };
|
||||
337775CD9091673D0ADDF325 /* TripDayAttributes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripDayAttributes.swift; sourceTree = "<group>"; };
|
||||
3DF4F4305878F949DD09193F /* NotificationService.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = NotificationService.entitlements; 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>"; };
|
||||
@@ -31,15 +82,35 @@
|
||||
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>"; };
|
||||
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; };
|
||||
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; };
|
||||
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>"; };
|
||||
8C2E47D0B005674C2B4ADA56 /* TREKWidgetsBundle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TREKWidgetsBundle.swift; 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; };
|
||||
A03A384FE54FAD1ED68E12DC /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
FFA8802204810ED580DE46F3 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
0A579FBACE2CF30B89370F8E /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
3FB1872A25267A8E88FCBE3C /* WidgetKit.framework in Frameworks */,
|
||||
2162CD349AFFDF070C16A58A /* SwiftUI.framework in Frameworks */,
|
||||
B65B7CFC62EEFF443491EA64 /* ActivityKit.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
504EC3011FED79650016851F /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -48,15 +119,33 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
C9AA4ADC0D397C620A35B6A3 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
2CFE220A73ACB585F3D4CB8F /* UserNotifications.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
0FC8613857BD976EB58ECE0B /* iOS */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
);
|
||||
name = iOS;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
504EC2FB1FED79650016851F = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
958DCC722DB07C7200EA8C5F /* debug.xcconfig */,
|
||||
504EC3061FED79650016851F /* App */,
|
||||
504EC3051FED79650016851F /* Products */,
|
||||
5198C9768EB9762A60701C92 /* Frameworks */,
|
||||
74C1FC6022E562372A71EEC9 /* NotificationService */,
|
||||
FB9226BC82835538BCE3DC0A /* TREKWidgets */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -64,6 +153,8 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
504EC3041FED79650016851F /* App.app */,
|
||||
A03A384FE54FAD1ED68E12DC /* NotificationService.appex */,
|
||||
702D0DF4A7E323CEA94CA03F /* TREKWidgets.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -88,6 +179,44 @@
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5198C9768EB9762A60701C92 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0FC8613857BD976EB58ECE0B /* iOS */,
|
||||
2EE43D236E661AA975612A4B /* UserNotifications.framework */,
|
||||
BC64330AC19625949E8DC05A /* WidgetKit.framework */,
|
||||
6E901288FC198E4D33BE754F /* SwiftUI.framework */,
|
||||
192E69C7E66646BC39566925 /* ActivityKit.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
74C1FC6022E562372A71EEC9 /* NotificationService */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1E7522315D47F086145DC4B8 /* Info.plist */,
|
||||
3DF4F4305878F949DD09193F /* NotificationService.entitlements */,
|
||||
FFA8802204810ED580DE46F3 /* NotificationService.swift */,
|
||||
);
|
||||
name = NotificationService;
|
||||
path = NotificationService;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FB9226BC82835538BCE3DC0A /* TREKWidgets */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BE41D7212DCB0C78E00A8462 /* Info.plist */,
|
||||
C7064A9DA92FCC8ACAA4F22A /* NextTripWidget.swift */,
|
||||
BBB2E19CD540AB30B44BECCA /* TREKWidgets.entitlements */,
|
||||
8C2E47D0B005674C2B4ADA56 /* TREKWidgetsBundle.swift */,
|
||||
337775CD9091673D0ADDF325 /* TripDayAttributes.swift */,
|
||||
3259F6C40216CECEBAC6F307 /* TripDayLiveActivity.swift */,
|
||||
DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */,
|
||||
);
|
||||
name = TREKWidgets;
|
||||
path = TREKWidgets;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -98,10 +227,13 @@
|
||||
504EC3001FED79650016851F /* Sources */,
|
||||
504EC3011FED79650016851F /* Frameworks */,
|
||||
504EC3021FED79650016851F /* Resources */,
|
||||
A8F12B42B22FCA041C6AA0DA /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
E23A0A46599B8EE7A5B74F45 /* PBXTargetDependency */,
|
||||
4CD7F5AF02CB806E4189489A /* PBXTargetDependency */,
|
||||
);
|
||||
name = App;
|
||||
packageProductDependencies = (
|
||||
@@ -111,6 +243,40 @@
|
||||
productReference = 504EC3041FED79650016851F /* App.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
9480DFBC2C2A112973DA468A /* NotificationService */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 4E3E5FA93A15425251E22D48 /* Build configuration list for PBXNativeTarget "NotificationService" */;
|
||||
buildPhases = (
|
||||
1EA0B85A2E70AC56F83C09C9 /* Sources */,
|
||||
C9AA4ADC0D397C620A35B6A3 /* Frameworks */,
|
||||
9706E0B1D8DD6E0542723B5D /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = NotificationService;
|
||||
productName = NotificationService;
|
||||
productReference = A03A384FE54FAD1ED68E12DC /* NotificationService.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
A3BE5653156025C97CA62C68 /* TREKWidgets */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 9DE86AC1D6A95987925D7982 /* Build configuration list for PBXNativeTarget "TREKWidgets" */;
|
||||
buildPhases = (
|
||||
92D923C146596E50655B451D /* Sources */,
|
||||
0A579FBACE2CF30B89370F8E /* Frameworks */,
|
||||
04A03741C8AD60E63D4B11E7 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = TREKWidgets;
|
||||
productName = TREKWidgets;
|
||||
productReference = 702D0DF4A7E323CEA94CA03F /* TREKWidgets.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@@ -149,11 +315,20 @@
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
504EC3031FED79650016851F /* App */,
|
||||
9480DFBC2C2A112973DA468A /* NotificationService */,
|
||||
A3BE5653156025C97CA62C68 /* TREKWidgets */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
04A03741C8AD60E63D4B11E7 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
504EC3021FED79650016851F /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -168,9 +343,24 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
9706E0B1D8DD6E0542723B5D /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
1EA0B85A2E70AC56F83C09C9 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A8FDD4DBE3F078BF935CC4B9 /* NotificationService.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
504EC3001FED79650016851F /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -179,11 +369,39 @@
|
||||
9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */,
|
||||
7A1E00052C0000000000A005 /* TrekBridgeViewController.swift in Sources */,
|
||||
7A1E00072C0000000000A007 /* TrekShellPlugin.swift in Sources */,
|
||||
0C2D60A4950D7293E1F282CB /* TripDayAttributes.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
92D923C146596E50655B451D /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
B4C0FC386F71D849E141C91E /* NextTripWidget.swift in Sources */,
|
||||
32797E66C09CF7579AC323FD /* TREKWidgetsBundle.swift in Sources */,
|
||||
492FA24FBB57059B0B5007CE /* TripDayAttributes.swift in Sources */,
|
||||
DF633789F2F667C4CF264C9E /* TripDayLiveActivity.swift in Sources */,
|
||||
7FB730DC35C234816EAE65AF /* WidgetSnapshot.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
4CD7F5AF02CB806E4189489A /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
name = TREKWidgets;
|
||||
target = A3BE5653156025C97CA62C68 /* TREKWidgets */;
|
||||
targetProxy = B8BBE7F7A64678D780A7F3D1 /* PBXContainerItemProxy */;
|
||||
};
|
||||
E23A0A46599B8EE7A5B74F45 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
name = NotificationService;
|
||||
target = 9480DFBC2C2A112973DA468A /* NotificationService */;
|
||||
targetProxy = 3BB8A38B2B2C8614B24CF2AD /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
504EC30B1FED79650016851F /* Main.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
@@ -204,6 +422,31 @@
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
3ECF3537A139DB5CAC23E502 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = TREKWidgets/TREKWidgets.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = TREKWidgets/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.2;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.liketrek.trek.widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
504EC3141FED79650016851F /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
|
||||
@@ -359,9 +602,95 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
686FA31AEC6FE5BA3EF95E99 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = NotificationService/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.notifications;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
A475CD70019910612C4D523B /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = NotificationService/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.notifications;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
C5621F5D75100AAEBB1BD22A /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = TREKWidgets/TREKWidgets.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = TREKWidgets/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.2;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.liketrek.trek.widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
4E3E5FA93A15425251E22D48 /* Build configuration list for PBXNativeTarget "NotificationService" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A475CD70019910612C4D523B /* Release */,
|
||||
686FA31AEC6FE5BA3EF95E99 /* Debug */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
@@ -380,6 +709,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
9DE86AC1D6A95987925D7982 /* Build configuration list for PBXNativeTarget "TREKWidgets" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
C5621F5D75100AAEBB1BD22A /* Release */,
|
||||
3ECF3537A139DB5CAC23E502 /* Debug */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
|
||||
@@ -2,5 +2,11 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>production</string>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.liketrek.trek.shared</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,16 +1,41 @@
|
||||
import UIKit
|
||||
import Capacitor
|
||||
import UserNotifications
|
||||
|
||||
@UIApplicationMain
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
|
||||
|
||||
var window: UIWindow?
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
// Override point for customization after application launch.
|
||||
// Taps on a TREK notification, and banners while the app is open.
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Push (TrekPush in TrekShellPlugin.swift)
|
||||
|
||||
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||
TrekPush.shared.didRegister(token: deviceToken)
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
||||
TrekPush.shared.didFail(error)
|
||||
}
|
||||
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification,
|
||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
||||
completionHandler([.banner, .list, .sound])
|
||||
}
|
||||
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse,
|
||||
withCompletionHandler completionHandler: @escaping () -> Void) {
|
||||
if let url = response.notification.request.content.userInfo["url"] as? String {
|
||||
TrekPush.shared.opened(url: url)
|
||||
}
|
||||
completionHandler()
|
||||
}
|
||||
|
||||
func applicationWillResignActive(_ application: UIApplication) {
|
||||
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
|
||||
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
|
||||
|
||||
@@ -71,6 +71,10 @@
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>TREK saves photos and exports you download from your trips back to your photo library.</string>
|
||||
<!-- navigator.geolocation in the WebView ("locate me" on the map). -->
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>TREK uses Face ID to unlock the app when you turn on the app lock.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>TREK uses your location to centre the map on you and to find places near you.</string>
|
||||
<!-- Deep links (@capacitor/app appUrlOpen), matching the intent-filter in
|
||||
@@ -82,6 +86,27 @@
|
||||
apple-app-site-association, and a self-hosted app has no single domain to
|
||||
verify against. SceneDelegate already forwards openURLContexts to
|
||||
Capacitor, so no Swift change is needed. -->
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>Booking document</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Viewer</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Alternate</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>com.adobe.pdf</string>
|
||||
<string>com.apple.ical.ics</string>
|
||||
<string>com.apple.pkpass</string>
|
||||
<string>com.apple.mail.email</string>
|
||||
<string>public.email-message</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<false/>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
|
||||
@@ -11,14 +11,97 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
window?.rootViewController = TrekBridgeViewController()
|
||||
window?.makeKeyAndVisible()
|
||||
|
||||
// Launched by "Open in TREK" on a booking document.
|
||||
for context in connectionOptions.urlContexts where context.url.isFileURL {
|
||||
TrekSharedFiles.receive(context.url)
|
||||
}
|
||||
SceneDelegateProxy.shared.scene(scene, willConnectTo: session, options: connectionOptions)
|
||||
}
|
||||
|
||||
// The app switcher takes its snapshot as the scene resigns active, so the
|
||||
// cover goes on there and comes off when the app is back.
|
||||
func sceneWillResignActive(_ scene: UIScene) {
|
||||
TrekPrivacyCover.show(in: window)
|
||||
}
|
||||
|
||||
func sceneDidBecomeActive(_ scene: UIScene) {
|
||||
TrekPrivacyCover.hide()
|
||||
}
|
||||
|
||||
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
|
||||
SceneDelegateProxy.shared.scene(scene, openURLContexts: URLContexts)
|
||||
// A booking document shared to TREK goes to the UI's import; anything
|
||||
// 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)
|
||||
if !others.isEmpty { SceneDelegateProxy.shared.scene(scene, openURLContexts: others) }
|
||||
}
|
||||
|
||||
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
|
||||
SceneDelegateProxy.shared.scene(scene, continue: userActivity)
|
||||
}
|
||||
}
|
||||
|
||||
/// The blur over the app while it sits in the app switcher, when the app lock
|
||||
/// is on (TrekShell.setPrivacyCover from the UI's lock setting).
|
||||
enum TrekPrivacyCover {
|
||||
private static let key = "TrekPrivacyCover"
|
||||
private static weak var cover: UIView?
|
||||
|
||||
static var enabled: Bool {
|
||||
get { UserDefaults.standard.bool(forKey: key) }
|
||||
set { UserDefaults.standard.set(newValue, forKey: key) }
|
||||
}
|
||||
|
||||
static func show(in window: UIWindow?) {
|
||||
guard enabled, cover == nil, let window else { return }
|
||||
let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemThickMaterial))
|
||||
view.frame = window.bounds
|
||||
view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
window.addSubview(view)
|
||||
cover = view
|
||||
}
|
||||
|
||||
static func hide() {
|
||||
cover?.removeFromSuperview()
|
||||
cover = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Booking documents shared to TREK ("Open in TREK"), held until the UI takes
|
||||
/// them (TrekShell.takeSharedFile) and announced if it is already running.
|
||||
enum TrekSharedFiles {
|
||||
/// Above this the file is not a booking confirmation, and base64 over the
|
||||
/// bridge would only stall the app.
|
||||
static let maxBytes = 15 * 1024 * 1024
|
||||
private(set) static var pending: [String: Any]?
|
||||
static var onReceive: (([String: Any]) -> Void)?
|
||||
|
||||
static func receive(_ url: URL) {
|
||||
let scoped = url.startAccessingSecurityScopedResource()
|
||||
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
|
||||
guard let data = try? Data(contentsOf: url), data.count <= maxBytes else { return }
|
||||
let file: [String: Any] = [
|
||||
"name": url.lastPathComponent,
|
||||
"mimeType": mimeType(for: url.pathExtension.lowercased()),
|
||||
"data": data.base64EncodedString(),
|
||||
]
|
||||
pending = file
|
||||
onReceive?(file)
|
||||
}
|
||||
|
||||
static func take() -> [String: Any]? {
|
||||
defer { pending = nil }
|
||||
return pending
|
||||
}
|
||||
|
||||
private static func mimeType(for ext: String) -> String {
|
||||
switch ext {
|
||||
case "pdf": return "application/pdf"
|
||||
case "ics": return "text/calendar"
|
||||
case "pkpass": return "application/vnd.apple.pkpass"
|
||||
case "eml": return "message/rfc822"
|
||||
default: return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import UIKit
|
||||
import Capacitor
|
||||
import CryptoKit
|
||||
import WebKit
|
||||
|
||||
/// Points the WebView at the TREK server the user picked instead of the bundled
|
||||
/// address screen. The UI then comes from that server, in the version it runs,
|
||||
@@ -17,11 +19,36 @@ class TrekBridgeViewController: CAPBridgeViewController {
|
||||
return descriptor
|
||||
}
|
||||
|
||||
/// The map tile store answers trek-tile:// (TrekTileSchemeHandler below). A
|
||||
/// scheme handler can only be set on the configuration the WebView is built from.
|
||||
private let tileHandler = TrekTileSchemeHandler()
|
||||
|
||||
override func webViewConfiguration(for instanceConfiguration: InstanceConfiguration) -> WKWebViewConfiguration {
|
||||
let configuration = super.webViewConfiguration(for: instanceConfiguration)
|
||||
configuration.setURLSchemeHandler(tileHandler, forURLScheme: TrekTileSchemeHandler.scheme)
|
||||
return configuration
|
||||
}
|
||||
|
||||
/// Kept alive here: WKWebView holds its navigation delegate weakly.
|
||||
private var navigationProxy: TrekNavigationProxy?
|
||||
|
||||
override func capacitorDidLoad() {
|
||||
TrekTileStore.shared.prune()
|
||||
// Capacitor shows its error page for every failed navigation, a replaced
|
||||
// one included: the UI moving to /login while the first page still loads
|
||||
// cancels that load (-999), and a first-time user landed on "Server not
|
||||
// reachable" with a server that was there all along.
|
||||
if let webView, let inner = webView.navigationDelegate {
|
||||
let proxy = TrekNavigationProxy(inner: inner)
|
||||
navigationProxy = proxy
|
||||
webView.navigationDelegate = proxy
|
||||
}
|
||||
bridge?.registerPluginInstance(TrekShellPlugin())
|
||||
// The swipe from the left edge that goes back in every iOS app. It walks
|
||||
// the SPA's history, the same entries Android's back button uses.
|
||||
webView?.allowsBackForwardNavigationGestures = true
|
||||
// WebKit's own swipe back slides away a screenshot of the whole page,
|
||||
// closes no sheet and takes every drag near the edge from the UI. The UI
|
||||
// draws its own (mobile/components/MEdgeBack.tsx, feature "edgeBack"):
|
||||
// the page follows the finger and an open sheet closes first.
|
||||
webView?.allowsBackForwardNavigationGestures = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,3 +69,254 @@ enum TrekServerStore {
|
||||
return support.appendingPathComponent("offline-start-page.html")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tile store (trek-tile://)
|
||||
|
||||
/// Map tiles the user looked at, on disk, for the WebView's maps
|
||||
/// (client/src/native/tileCache.ts). WKWebView runs no service worker for a
|
||||
/// self-hosted server, so without this nothing kept a tile once the app closed.
|
||||
///
|
||||
/// Each tile lives as `<sha256>.bin` with its content type in `<sha256>.type`.
|
||||
/// A tile older than 30 days is never served again (Mapbox's limit for a device
|
||||
/// cache, applied to every provider) and is fetched anew. The store keeps under
|
||||
/// 250 MB: past that, the tiles used least recently go first. It sits in Caches,
|
||||
/// which iOS may empty under storage pressure and never backs up.
|
||||
final class TrekTileStore {
|
||||
static let shared = TrekTileStore()
|
||||
static let maxAge: TimeInterval = 30 * 24 * 60 * 60
|
||||
static let maxBytes: Int64 = 250 * 1024 * 1024
|
||||
/// Pruning brings the store down to this share of the limit, so it does not
|
||||
/// run again on the very next tile.
|
||||
static let pruneTarget = 0.8
|
||||
|
||||
let directory: URL
|
||||
let queue = DispatchQueue(label: "com.liketrek.trek.tiles", qos: .utility)
|
||||
private var writesSincePrune = 0
|
||||
|
||||
private init() {
|
||||
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
directory = caches.appendingPathComponent("trek-tiles", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
/// The tile's identity without what changes per user or session: tokens and
|
||||
/// keys come out, the rest of the query is sorted.
|
||||
static func key(for url: URL) -> String {
|
||||
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||
let dropped: Set<String> = ["access_token", "sku", "key", "api_key", "apikey"]
|
||||
let kept = components?.queryItems?
|
||||
.filter { !dropped.contains($0.name.lowercased()) }
|
||||
.sorted { $0.name < $1.name }
|
||||
components?.queryItems = (kept?.isEmpty ?? true) ? nil : kept
|
||||
let identity = components?.string ?? url.absoluteString
|
||||
return SHA256.hash(data: Data(identity.utf8)).map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
private func dataURL(_ key: String) -> URL { directory.appendingPathComponent(key + ".bin") }
|
||||
private func typeURL(_ key: String) -> URL { directory.appendingPathComponent(key + ".type") }
|
||||
|
||||
/// A stored tile younger than 30 days, marked as just used. Call on `queue`.
|
||||
func read(_ key: String) -> (data: Data, type: String)? {
|
||||
let file = dataURL(key)
|
||||
guard let attributes = try? FileManager.default.attributesOfItem(atPath: file.path),
|
||||
let created = attributes[.creationDate] as? Date else { return nil }
|
||||
if Date().timeIntervalSince(created) > TrekTileStore.maxAge {
|
||||
remove(key)
|
||||
return nil
|
||||
}
|
||||
guard let data = try? Data(contentsOf: file) else { return nil }
|
||||
let type = (try? String(contentsOf: typeURL(key), encoding: .utf8)) ?? "application/octet-stream"
|
||||
try? FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: file.path)
|
||||
return (data, type)
|
||||
}
|
||||
|
||||
func write(_ key: String, data: Data, type: String) {
|
||||
queue.async {
|
||||
try? data.write(to: self.dataURL(key), options: .atomic)
|
||||
try? type.write(to: self.typeURL(key), atomically: true, encoding: .utf8)
|
||||
self.writesSincePrune += 1
|
||||
if self.writesSincePrune >= 200 { self.pruneNow() }
|
||||
}
|
||||
}
|
||||
|
||||
private func remove(_ key: String) {
|
||||
try? FileManager.default.removeItem(at: dataURL(key))
|
||||
try? FileManager.default.removeItem(at: typeURL(key))
|
||||
}
|
||||
|
||||
private struct Entry {
|
||||
let key: String
|
||||
let size: Int64
|
||||
let used: Date
|
||||
let created: Date
|
||||
}
|
||||
|
||||
private func entries() -> [Entry] {
|
||||
let keys: [URLResourceKey] = [.fileSizeKey, .contentModificationDateKey, .creationDateKey]
|
||||
let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: keys)) ?? []
|
||||
return files.filter { $0.pathExtension == "bin" }.compactMap { file in
|
||||
guard let values = try? file.resourceValues(forKeys: Set(keys)) else { return nil }
|
||||
return Entry(
|
||||
key: file.deletingPathExtension().lastPathComponent,
|
||||
size: Int64(values.fileSize ?? 0),
|
||||
used: values.contentModificationDate ?? .distantPast,
|
||||
created: values.creationDate ?? .distantPast
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops what is past 30 days, then the least recently used tiles while the
|
||||
/// store is over its limit.
|
||||
func prune() { queue.async { self.pruneNow() } }
|
||||
|
||||
private func pruneNow() {
|
||||
writesSincePrune = 0
|
||||
let now = Date()
|
||||
var live: [Entry] = []
|
||||
for entry in entries() {
|
||||
if now.timeIntervalSince(entry.created) > TrekTileStore.maxAge { remove(entry.key) } else { live.append(entry) }
|
||||
}
|
||||
var total = live.reduce(Int64(0)) { $0 + $1.size }
|
||||
guard total > TrekTileStore.maxBytes else { return }
|
||||
let target = Int64(Double(TrekTileStore.maxBytes) * TrekTileStore.pruneTarget)
|
||||
for entry in live.sorted(by: { $0.used < $1.used }) {
|
||||
if total <= target { break }
|
||||
remove(entry.key)
|
||||
total -= entry.size
|
||||
}
|
||||
}
|
||||
|
||||
func usage(_ done: @escaping (Int64) -> Void) {
|
||||
queue.async { done(self.entries().reduce(Int64(0)) { $0 + $1.size }) }
|
||||
}
|
||||
|
||||
func clear(_ done: @escaping () -> Void) {
|
||||
queue.async {
|
||||
try? FileManager.default.removeItem(at: self.directory)
|
||||
try? FileManager.default.createDirectory(at: self.directory, withIntermediateDirectories: true)
|
||||
done()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serves trek-tile://host/path from the store, or from https://host/path when
|
||||
/// it has no fresh copy, storing what comes back. Offline and not stored, the
|
||||
/// map gets an error and draws nothing there, as it would for a missing tile.
|
||||
final class TrekTileSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
static let scheme = "trek-tile"
|
||||
|
||||
/// Tasks WebKit still wants an answer for. Touched on the main thread only:
|
||||
/// start and stop arrive there, and every answer is posted back there.
|
||||
private var active = Set<ObjectIdentifier>()
|
||||
|
||||
private let session: URLSession = {
|
||||
let configuration = URLSessionConfiguration.default
|
||||
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
configuration.timeoutIntervalForRequest = 20
|
||||
configuration.httpMaximumConnectionsPerHost = 4
|
||||
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0"
|
||||
// A unique app User-Agent, as OpenFreeMap and OpenStreetMap ask of apps.
|
||||
configuration.httpAdditionalHeaders = ["User-Agent": "TREK-App/\(version) (iOS; +https://liketrek.com)"]
|
||||
return URLSession(configuration: configuration)
|
||||
}()
|
||||
|
||||
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
|
||||
let id = ObjectIdentifier(urlSchemeTask)
|
||||
guard let url = urlSchemeTask.request.url,
|
||||
var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
urlSchemeTask.didFailWithError(URLError(.badURL))
|
||||
return
|
||||
}
|
||||
components.scheme = "https"
|
||||
guard let remote = components.url else {
|
||||
urlSchemeTask.didFailWithError(URLError(.badURL))
|
||||
return
|
||||
}
|
||||
active.insert(id)
|
||||
let key = TrekTileStore.key(for: remote)
|
||||
let store = TrekTileStore.shared
|
||||
let accept = urlSchemeTask.request.value(forHTTPHeaderField: "Accept")
|
||||
store.queue.async {
|
||||
if let hit = store.read(key) {
|
||||
DispatchQueue.main.async {
|
||||
self.respond(urlSchemeTask, id: id, url: url, status: 200, type: hit.type, data: hit.data)
|
||||
}
|
||||
return
|
||||
}
|
||||
var request = URLRequest(url: remote)
|
||||
if let accept { request.setValue(accept, forHTTPHeaderField: "Accept") }
|
||||
self.session.dataTask(with: request) { data, response, _ in
|
||||
guard let data, let http = response as? HTTPURLResponse else {
|
||||
DispatchQueue.main.async {
|
||||
self.respond(urlSchemeTask, id: id, url: url, status: 504, type: "text/plain", data: Data())
|
||||
}
|
||||
return
|
||||
}
|
||||
let type = http.value(forHTTPHeaderField: "Content-Type") ?? "application/octet-stream"
|
||||
if http.statusCode == 200, !data.isEmpty { store.write(key, data: data, type: type) }
|
||||
DispatchQueue.main.async {
|
||||
self.respond(urlSchemeTask, id: id, url: url, status: http.statusCode, type: type, data: data)
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
|
||||
active.remove(ObjectIdentifier(urlSchemeTask))
|
||||
}
|
||||
|
||||
/// Answers once, and never after WebKit stopped the task: that would throw.
|
||||
private func respond(_ task: WKURLSchemeTask, id: ObjectIdentifier, url: URL, status: Int, type: String, data: Data) {
|
||||
guard active.remove(id) != nil else { return }
|
||||
let headers = [
|
||||
"Content-Type": type,
|
||||
"Content-Length": String(data.count),
|
||||
// The page's origin is the user's server; WebGL reads tiles cross-origin.
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Cache-Control": "no-store",
|
||||
]
|
||||
guard let response = HTTPURLResponse(url: url, statusCode: status, httpVersion: "HTTP/1.1", headerFields: headers) else {
|
||||
task.didFailWithError(URLError(.badServerResponse))
|
||||
return
|
||||
}
|
||||
task.didReceive(response)
|
||||
task.didReceive(data)
|
||||
task.didFinish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Stands in front of Capacitor's navigation delegate and hands it everything
|
||||
/// but the failures of a navigation that another one replaced: a cancelled
|
||||
/// load (NSURLErrorCancelled) or an interrupted frame load (WebKit 102). Those
|
||||
/// are no fault, and Capacitor would answer them with its error page.
|
||||
final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
|
||||
private let inner: WKNavigationDelegate
|
||||
|
||||
init(inner: WKNavigationDelegate) {
|
||||
self.inner = inner
|
||||
}
|
||||
|
||||
static func isReplaced(_ error: Error) -> Bool {
|
||||
let error = error as NSError
|
||||
return (error.domain == NSURLErrorDomain && error.code == NSURLErrorCancelled)
|
||||
|| (error.domain == "WebKitErrorDomain" && error.code == 102)
|
||||
}
|
||||
|
||||
override func responds(to aSelector: Selector!) -> Bool {
|
||||
super.responds(to: aSelector) || (inner as AnyObject).responds(to: aSelector)
|
||||
}
|
||||
|
||||
override func forwardingTarget(for aSelector: Selector!) -> Any? {
|
||||
(inner as AnyObject).responds(to: aSelector) ? inner : nil
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
if Self.isReplaced(error) { return }
|
||||
inner.webView?(webView, didFail: navigation, withError: error)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||
if Self.isReplaced(error) { return }
|
||||
inner.webView?(webView, didFailProvisionalNavigation: navigation, withError: error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import AuthenticationServices
|
||||
import Capacitor
|
||||
import CoreLocation
|
||||
import CryptoKit
|
||||
import Security
|
||||
import UserNotifications
|
||||
import WidgetKit
|
||||
#if canImport(ActivityKit)
|
||||
import ActivityKit
|
||||
#endif
|
||||
import LocalAuthentication
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
@@ -16,8 +25,224 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
CAPPluginMethod(name: "authenticate", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "refreshOfflineCopy", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "openOfflineCopy", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "getCapabilities", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "getTileCacheUsage", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "clearTileCache", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "biometryInfo", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "authenticateBiometric", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "setPrivacyCover", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "startLocation", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "stopLocation", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "takeSharedFile", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "registerPush", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "takeOpenedPush", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "setWidgetSnapshot", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "syncTripDay", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "endTripDay", returnType: CAPPluginReturnPromise),
|
||||
]
|
||||
|
||||
/// What this binary offers the server's UI, raised with every native feature
|
||||
/// an older binary lacks. The UI asks before it calls (native/capabilities.ts),
|
||||
/// and a server can require a minimum (minNativeShellApi in /api/health/features).
|
||||
static 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"]
|
||||
|
||||
// MARK: - Shared booking documents
|
||||
|
||||
override public func load() {
|
||||
TrekSharedFiles.onReceive = { [weak self] file in
|
||||
DispatchQueue.main.async { self?.notifyListeners("sharedFile", data: file) }
|
||||
}
|
||||
TrekPush.shared.onOpen = { [weak self] url in
|
||||
DispatchQueue.main.async { self?.notifyListeners("pushOpened", data: ["url": url]) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Widgets and the travel-day Live Activity
|
||||
|
||||
/// Stores what the home-screen widget shows (native/widgets.ts builds it, texts
|
||||
/// already translated) where the widget extension reads it, and redraws it.
|
||||
@objc func setWidgetSnapshot(_ call: CAPPluginCall) {
|
||||
guard let json = call.getString("json"), let data = json.data(using: .utf8) else {
|
||||
call.reject("json is required")
|
||||
return
|
||||
}
|
||||
TrekSharedKeychain.write("widget.snapshot", data)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
/// Starts the travel day's Live Activity, or updates the one running.
|
||||
@objc func syncTripDay(_ call: CAPPluginCall) {
|
||||
guard let title = call.getString("tripTitle"), let path = call.getString("path"),
|
||||
let dayLabel = call.getString("dayLabel"), let nextName = call.getString("nextName") else {
|
||||
call.reject("tripTitle, path, dayLabel and nextName are required")
|
||||
return
|
||||
}
|
||||
#if canImport(ActivityKit)
|
||||
if #available(iOS 16.2, *) {
|
||||
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
|
||||
call.resolve(["running": false])
|
||||
return
|
||||
}
|
||||
let state = TripDayAttributes.ContentState(
|
||||
dayLabel: dayLabel,
|
||||
nextName: nextName,
|
||||
nextTime: call.getString("nextTime"),
|
||||
done: call.getInt("done") ?? 0,
|
||||
total: call.getInt("total") ?? 0
|
||||
)
|
||||
// Stale at the end of the day, so a forgotten one fades out on its own.
|
||||
let stale = Calendar.current.startOfDay(for: Date().addingTimeInterval(86_400))
|
||||
let content = ActivityContent(state: state, staleDate: stale)
|
||||
if let running = Activity<TripDayAttributes>.activities.first(where: { $0.attributes.path == path }) {
|
||||
Task { await running.update(content) }
|
||||
} else {
|
||||
for other in Activity<TripDayAttributes>.activities {
|
||||
Task { await other.end(nil, dismissalPolicy: .immediate) }
|
||||
}
|
||||
_ = try? Activity.request(attributes: TripDayAttributes(tripTitle: title, path: path), content: content)
|
||||
}
|
||||
call.resolve(["running": true])
|
||||
return
|
||||
}
|
||||
#endif
|
||||
call.resolve(["running": false])
|
||||
}
|
||||
|
||||
@objc func endTripDay(_ call: CAPPluginCall) {
|
||||
#if canImport(ActivityKit)
|
||||
if #available(iOS 16.2, *) {
|
||||
for activity in Activity<TripDayAttributes>.activities {
|
||||
Task { await activity.end(nil, dismissalPolicy: .immediate) }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
// MARK: - Push
|
||||
|
||||
/// Asks for permission, registers with APNs and answers with a Web Push
|
||||
/// subscription the server stores like a browser's (native/push.ts): the
|
||||
/// relay endpoint for this device's token, and this device's keys, which the
|
||||
/// notification extension uses to decrypt.
|
||||
@objc func registerPush(_ call: CAPPluginCall) {
|
||||
TrekPush.shared.register { result in
|
||||
switch result {
|
||||
case .success(let subscription): call.resolve(subscription)
|
||||
case .failure(let error):
|
||||
let code = (error as? TrekPush.PushError) == .denied ? "DENIED" : "FAILED"
|
||||
call.reject(error.localizedDescription, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The notification the app was opened from, before the UI was there to hear of it.
|
||||
@objc func takeOpenedPush(_ call: CAPPluginCall) {
|
||||
if let url = TrekPush.shared.takeOpened() {
|
||||
call.resolve(["url": url])
|
||||
} else {
|
||||
call.resolve(["url": NSNull()])
|
||||
}
|
||||
}
|
||||
|
||||
/// The document shared to TREK before the UI was there to hear of it.
|
||||
@objc func takeSharedFile(_ call: CAPPluginCall) {
|
||||
if let file = TrekSharedFiles.take() {
|
||||
call.resolve(["file": file])
|
||||
} else {
|
||||
call.resolve(["file": NSNull()])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Location
|
||||
|
||||
/// One CoreLocation stream for the UI's navigator.geolocation (native/geolocation.ts).
|
||||
/// The WebView's own asks for every server origin, and again and again; this
|
||||
/// is the one system prompt the app's location string describes.
|
||||
private lazy var location = TrekLocation { [weak self] payload in
|
||||
self?.notifyListeners("location", data: payload)
|
||||
}
|
||||
|
||||
@objc func startLocation(_ call: CAPPluginCall) {
|
||||
DispatchQueue.main.async {
|
||||
self.location.start(highAccuracy: call.getBool("highAccuracy") ?? false)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func stopLocation(_ call: CAPPluginCall) {
|
||||
DispatchQueue.main.async {
|
||||
self.location.stop()
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - App lock
|
||||
|
||||
/// Which biometry the device offers for the app lock, if any.
|
||||
@objc func biometryInfo(_ call: CAPPluginCall) {
|
||||
let context = LAContext()
|
||||
var error: NSError?
|
||||
let available = context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error)
|
||||
let type: String
|
||||
switch context.biometryType {
|
||||
case .faceID: type = "faceID"
|
||||
case .touchID: type = "touchID"
|
||||
default: type = "none"
|
||||
}
|
||||
call.resolve(["available": available, "type": type])
|
||||
}
|
||||
|
||||
/// Face ID / Touch ID with the passcode as the way out, the way system apps
|
||||
/// lock. Rejects with CANCELLED when the user backs out.
|
||||
@objc func authenticateBiometric(_ call: CAPPluginCall) {
|
||||
let reason = call.getString("reason") ?? "Unlock TREK"
|
||||
let context = LAContext()
|
||||
context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { ok, error in
|
||||
if ok {
|
||||
call.resolve(["ok": true])
|
||||
} else if let laError = error as? LAError, [.userCancel, .appCancel, .systemCancel].contains(laError.code) {
|
||||
call.reject("Unlock cancelled", "CANCELLED")
|
||||
} else {
|
||||
call.reject(error?.localizedDescription ?? "Unlock failed", "FAILED")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// While on, the app switcher shows a blurred cover instead of the screen
|
||||
/// (SceneDelegate), so a locked app does not show its content there.
|
||||
@objc func setPrivacyCover(_ call: CAPPluginCall) {
|
||||
TrekPrivacyCover.enabled = call.getBool("enabled") ?? false
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
// MARK: - Map tile store
|
||||
|
||||
/// How much the stored map tiles take on disk, for the offline settings.
|
||||
@objc func getTileCacheUsage(_ call: CAPPluginCall) {
|
||||
TrekTileStore.shared.usage { bytes in
|
||||
call.resolve(["bytes": Int(bytes), "limit": Int(TrekTileStore.maxBytes)])
|
||||
}
|
||||
}
|
||||
|
||||
@objc func clearTileCache(_ call: CAPPluginCall) {
|
||||
TrekTileStore.shared.clear { call.resolve() }
|
||||
}
|
||||
|
||||
@objc func getCapabilities(_ call: CAPPluginCall) {
|
||||
let info = Bundle.main.infoDictionary
|
||||
call.resolve([
|
||||
"shellApi": TrekShellPlugin.shellApi,
|
||||
"platform": "ios",
|
||||
"appVersion": info?["CFBundleShortVersionString"] as? String ?? "",
|
||||
"build": info?["CFBundleVersion"] as? String ?? "",
|
||||
"features": TrekShellPlugin.features,
|
||||
])
|
||||
}
|
||||
|
||||
private var authSession: ASWebAuthenticationSession?
|
||||
|
||||
@objc func getServer(_ call: CAPPluginCall) {
|
||||
@@ -140,3 +365,222 @@ 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.
|
||||
final class TrekLocation: NSObject, CLLocationManagerDelegate {
|
||||
private let manager = CLLocationManager()
|
||||
private let emit: ([String: Any]) -> Void
|
||||
private var running = false
|
||||
|
||||
init(emit: @escaping ([String: Any]) -> Void) {
|
||||
self.emit = emit
|
||||
super.init()
|
||||
manager.delegate = self
|
||||
}
|
||||
|
||||
func start(highAccuracy: Bool) {
|
||||
manager.desiredAccuracy = highAccuracy ? kCLLocationAccuracyBest : kCLLocationAccuracyHundredMeters
|
||||
running = true
|
||||
switch manager.authorizationStatus {
|
||||
case .notDetermined:
|
||||
manager.requestWhenInUseAuthorization()
|
||||
case .denied, .restricted:
|
||||
emitError(code: 1, message: "Location permission denied")
|
||||
default:
|
||||
manager.startUpdatingLocation()
|
||||
manager.startUpdatingHeading()
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
running = false
|
||||
manager.stopUpdatingLocation()
|
||||
manager.stopUpdatingHeading()
|
||||
}
|
||||
|
||||
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
guard running else { return }
|
||||
switch manager.authorizationStatus {
|
||||
case .authorizedWhenInUse, .authorizedAlways:
|
||||
manager.startUpdatingLocation()
|
||||
manager.startUpdatingHeading()
|
||||
case .denied, .restricted:
|
||||
emitError(code: 1, message: "Location permission denied")
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
guard let last = locations.last else { return }
|
||||
emit([
|
||||
"latitude": last.coordinate.latitude,
|
||||
"longitude": last.coordinate.longitude,
|
||||
"accuracy": last.horizontalAccuracy,
|
||||
"altitude": last.verticalAccuracy >= 0 ? last.altitude as Any : NSNull(),
|
||||
"speed": last.speed >= 0 ? last.speed as Any : NSNull(),
|
||||
"heading": last.course >= 0 ? last.course as Any : NSNull(),
|
||||
"timestamp": last.timestamp.timeIntervalSince1970 * 1000,
|
||||
])
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||
let code = (error as? CLError)?.code == .denied ? 1 : 2
|
||||
emitError(code: code, message: error.localizedDescription)
|
||||
}
|
||||
|
||||
private func emitError(code: Int, message: String) {
|
||||
emit(["error": ["code": code, "message": message]])
|
||||
}
|
||||
}
|
||||
|
||||
/// The app's side of TREK push: an APNs token, the device's Web Push keys in the
|
||||
/// keychain group the notification extension shares, and the relay endpoint
|
||||
/// that ties them together.
|
||||
final class TrekPush {
|
||||
static let shared = TrekPush()
|
||||
static let relay = "https://push.liketrek.com/v1/apns"
|
||||
/// The keychain group the notification extension reads the keys from.
|
||||
static let keychainGroup = "com.liketrek.trek.shared"
|
||||
|
||||
enum PushError: Error, Equatable { case denied, failed }
|
||||
|
||||
private var pending: [(Result<[String: Any], Error>) -> Void] = []
|
||||
private var openedURL: String?
|
||||
var onOpen: ((String) -> Void)?
|
||||
|
||||
func register(_ done: @escaping (Result<[String: Any], Error>) -> Void) {
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
|
||||
guard granted else {
|
||||
done(.failure(PushError.denied))
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.pending.append(done)
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func didRegister(token: Data) {
|
||||
let hex = token.map { String(format: "%02x", $0) }.joined()
|
||||
#if DEBUG
|
||||
let environment = "sandbox"
|
||||
#else
|
||||
let environment = "production"
|
||||
#endif
|
||||
let result: Result<[String: Any], Error>
|
||||
if let keys = TrekPushKeys.loadOrCreate() {
|
||||
result = .success([
|
||||
"endpoint": "\(TrekPush.relay)/\(environment)/\(hex)",
|
||||
"keys": ["p256dh": keys.publicKey, "auth": keys.auth],
|
||||
])
|
||||
} else {
|
||||
result = .failure(PushError.failed)
|
||||
}
|
||||
let waiting = pending
|
||||
pending.removeAll()
|
||||
waiting.forEach { $0(result) }
|
||||
}
|
||||
|
||||
func didFail(_ error: Error) {
|
||||
let waiting = pending
|
||||
pending.removeAll()
|
||||
waiting.forEach { $0(.failure(error)) }
|
||||
}
|
||||
|
||||
func opened(url: String) {
|
||||
if let onOpen { onOpen(url) } else { openedURL = url }
|
||||
}
|
||||
|
||||
func takeOpened() -> String? {
|
||||
defer { openedURL = nil }
|
||||
return openedURL
|
||||
}
|
||||
}
|
||||
|
||||
/// The device's Web Push key pair (P-256) and auth secret, kept in the shared
|
||||
/// keychain group so the notification extension can decrypt what the server
|
||||
/// encrypted for them (RFC 8291). Base64url without padding, as the server reads them.
|
||||
enum TrekPushKeys {
|
||||
private static let privateKeyAccount = "push.privateKey"
|
||||
private static let authAccount = "push.auth"
|
||||
|
||||
static func loadOrCreate() -> (publicKey: String, auth: String)? {
|
||||
if let raw = TrekSharedKeychain.read(privateKeyAccount), let auth = TrekSharedKeychain.read(authAccount),
|
||||
let key = try? P256.KeyAgreement.PrivateKey(rawRepresentation: raw) {
|
||||
return (base64url(key.publicKey.x963Representation), base64url(auth))
|
||||
}
|
||||
let key = P256.KeyAgreement.PrivateKey()
|
||||
var auth = Data(count: 16)
|
||||
let status = auth.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
|
||||
guard status == errSecSuccess,
|
||||
TrekSharedKeychain.write(privateKeyAccount, key.rawRepresentation),
|
||||
TrekSharedKeychain.write(authAccount, auth) else { return nil }
|
||||
return (base64url(key.publicKey.x963Representation), base64url(auth))
|
||||
}
|
||||
|
||||
static func base64url(_ data: Data) -> String {
|
||||
data.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
}
|
||||
|
||||
/// Items the app shares with its extensions (the notification extension, the
|
||||
/// widgets) through one keychain group, so no App Group is needed: the push keys
|
||||
/// and the widget snapshot.
|
||||
enum TrekSharedKeychain {
|
||||
static let service = "com.liketrek.trek.push"
|
||||
|
||||
private static func query(_ account: String) -> [String: Any] {
|
||||
[
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrAccessGroup as String: accessGroup,
|
||||
]
|
||||
}
|
||||
|
||||
/// The team-prefixed shared group, read off a probe item in the default group.
|
||||
static var accessGroup: String = {
|
||||
let probe: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: "push.groupProbe",
|
||||
kSecAttrService as String: service,
|
||||
kSecReturnAttributes as String: true,
|
||||
]
|
||||
var result: CFTypeRef?
|
||||
var status = SecItemCopyMatching(probe as CFDictionary, &result)
|
||||
if status == errSecItemNotFound {
|
||||
var add = probe
|
||||
add.removeValue(forKey: kSecReturnAttributes as String)
|
||||
add[kSecValueData as String] = Data()
|
||||
SecItemAdd(add as CFDictionary, nil)
|
||||
status = SecItemCopyMatching(probe as CFDictionary, &result)
|
||||
}
|
||||
// The default group is the first one in the entitlement: "TEAMID.com.liketrek.trek.shared".
|
||||
if status == errSecSuccess, let attributes = result as? [String: Any],
|
||||
let group = attributes[kSecAttrAccessGroup as String] as? String,
|
||||
let prefix = group.split(separator: ".").first {
|
||||
return "\(prefix).\(TrekPush.keychainGroup)"
|
||||
}
|
||||
return TrekPush.keychainGroup
|
||||
}()
|
||||
|
||||
static func read(_ account: String) -> Data? {
|
||||
var q = query(account)
|
||||
q[kSecReturnData as String] = true
|
||||
var result: CFTypeRef?
|
||||
return SecItemCopyMatching(q as CFDictionary, &result) == errSecSuccess ? result as? Data : nil
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
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,31 @@
|
||||
<?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 Notifications</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>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.usernotifications.service</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).NotificationService</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,109 @@
|
||||
import CryptoKit
|
||||
import Security
|
||||
import UserNotifications
|
||||
|
||||
/// Decrypts a TREK push before iOS shows it.
|
||||
///
|
||||
/// The user's server encrypts each notification for this device exactly as Web
|
||||
/// Push does for a browser (RFC 8291, aes128gcm), and liketrek's relay hands the
|
||||
/// ciphertext to APNs as `enc`, with a placeholder text and mutable-content set.
|
||||
/// Here the device's keys (the app made them, TrekPushKeys in the app target)
|
||||
/// open it, and the real title, text and link replace the placeholder. Without
|
||||
/// the keys, or if anything does not add up, the placeholder stays: a
|
||||
/// notification arrives either way, and nothing on the way could read it.
|
||||
final class NotificationService: UNNotificationServiceExtension {
|
||||
private var contentHandler: ((UNNotificationContent) -> Void)?
|
||||
private var content: UNMutableNotificationContent?
|
||||
|
||||
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
|
||||
self.contentHandler = contentHandler
|
||||
let content = (request.content.mutableCopy() as? UNMutableNotificationContent) ?? UNMutableNotificationContent()
|
||||
self.content = content
|
||||
if let encoded = request.content.userInfo["enc"] as? String,
|
||||
let record = Data(base64Encoded: encoded),
|
||||
let keys = PushKeys.load(),
|
||||
let plaintext = WebPushRecord.open(record, keys: keys),
|
||||
let payload = try? JSONSerialization.jsonObject(with: plaintext) as? [String: Any] {
|
||||
if let title = payload["title"] as? String, !title.isEmpty { content.title = title }
|
||||
if let body = payload["body"] as? String { content.body = body }
|
||||
if let url = payload["url"] as? String { content.userInfo["url"] = url }
|
||||
if let tag = payload["tag"] as? String { content.threadIdentifier = tag }
|
||||
}
|
||||
contentHandler(content)
|
||||
}
|
||||
|
||||
override func serviceExtensionTimeWillExpire() {
|
||||
if let contentHandler, let content { contentHandler(content) }
|
||||
}
|
||||
}
|
||||
|
||||
/// The device's Web Push keys, from the keychain group it shares with the app.
|
||||
struct PushKeys {
|
||||
let privateKey: P256.KeyAgreement.PrivateKey
|
||||
let auth: Data
|
||||
|
||||
static func load() -> PushKeys? {
|
||||
guard let raw = read("push.privateKey"), let auth = read("push.auth"),
|
||||
let key = try? P256.KeyAgreement.PrivateKey(rawRepresentation: raw) else { return nil }
|
||||
return PushKeys(privateKey: key, auth: auth)
|
||||
}
|
||||
|
||||
private static func read(_ account: String) -> Data? {
|
||||
// No access group named: the extension's first keychain group is the
|
||||
// shared one (its entitlements list only that), where the app stored them.
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: "com.liketrek.trek.push",
|
||||
kSecAttrAccount as String: account,
|
||||
kSecReturnData as String: true,
|
||||
]
|
||||
var result: CFTypeRef?
|
||||
return SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess ? result as? Data : nil
|
||||
}
|
||||
}
|
||||
|
||||
/// One aes128gcm record (RFC 8188) with Web Push keys (RFC 8291), opened.
|
||||
enum WebPushRecord {
|
||||
static func open(_ record: Data, keys: PushKeys) -> Data? {
|
||||
let bytes = [UInt8](record)
|
||||
// salt (16) | record size (4) | key id length (1) | key id (the sender's public key)
|
||||
guard bytes.count > 21 else { return nil }
|
||||
let salt = Data(bytes[0..<16])
|
||||
let idLength = Int(bytes[20])
|
||||
guard bytes.count > 21 + idLength + 16 else { return nil }
|
||||
let senderKeyBytes = Data(bytes[21..<(21 + idLength)])
|
||||
let ciphertext = Data(bytes[(21 + idLength)...])
|
||||
|
||||
guard let senderKey = try? P256.KeyAgreement.PublicKey(x963Representation: senderKeyBytes),
|
||||
let shared = try? keys.privateKey.sharedSecretFromKeyAgreement(with: senderKey) else { return nil }
|
||||
let sharedBytes = shared.withUnsafeBytes { Data($0) }
|
||||
let receiverKey = keys.privateKey.publicKey.x963Representation
|
||||
|
||||
// IKM = HKDF(auth, ecdh, "WebPush: info" 0 receiver sender, 32)
|
||||
var keyInfo = Data("WebPush: info".utf8)
|
||||
keyInfo.append(0)
|
||||
keyInfo.append(receiverKey)
|
||||
keyInfo.append(senderKeyBytes)
|
||||
let prkKey = HKDF<SHA256>.extract(inputKeyMaterial: SymmetricKey(data: sharedBytes), salt: keys.auth)
|
||||
let ikm = HKDF<SHA256>.expand(pseudoRandomKey: prkKey, info: keyInfo, outputByteCount: 32)
|
||||
|
||||
let prk = HKDF<SHA256>.extract(inputKeyMaterial: ikm, salt: salt)
|
||||
var cekInfo = Data("Content-Encoding: aes128gcm".utf8)
|
||||
cekInfo.append(0)
|
||||
var nonceInfo = Data("Content-Encoding: nonce".utf8)
|
||||
nonceInfo.append(0)
|
||||
let cek = HKDF<SHA256>.expand(pseudoRandomKey: prk, info: cekInfo, outputByteCount: 16)
|
||||
let nonceKey = HKDF<SHA256>.expand(pseudoRandomKey: prk, info: nonceInfo, outputByteCount: 12)
|
||||
let nonceBytes = nonceKey.withUnsafeBytes { Data($0) }
|
||||
|
||||
guard let nonce = try? AES.GCM.Nonce(data: nonceBytes),
|
||||
let box = try? AES.GCM.SealedBox(nonce: nonce, ciphertext: ciphertext.dropLast(16), tag: ciphertext.suffix(16)),
|
||||
let padded = try? AES.GCM.open(box, using: cek) else { return nil }
|
||||
|
||||
// The last record ends in 0x02 and optional zero padding.
|
||||
var end = padded.count
|
||||
while end > 0 && padded[padded.startIndex + end - 1] == 0 { end -= 1 }
|
||||
guard end > 0, padded[padded.startIndex + end - 1] == 2 else { return nil }
|
||||
return padded.prefix(end - 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?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>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,118 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
/// "Next trip" on the home screen: the trip and how far off it is, or while it
|
||||
/// runs, today's day and the next stops (medium size).
|
||||
struct NextTripEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let snapshot: WidgetSnapshot?
|
||||
}
|
||||
|
||||
struct NextTripProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> NextTripEntry {
|
||||
NextTripEntry(date: Date(), snapshot: nil)
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (NextTripEntry) -> Void) {
|
||||
completion(NextTripEntry(date: Date(), snapshot: WidgetSnapshot.load()))
|
||||
}
|
||||
|
||||
/// The app reloads the timeline whenever it writes a new snapshot; past
|
||||
/// midnight the countdown moves on by itself.
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<NextTripEntry>) -> Void) {
|
||||
let now = Date()
|
||||
let midnight = Calendar.current.startOfDay(for: now.addingTimeInterval(86_400))
|
||||
let entry = NextTripEntry(date: now, snapshot: WidgetSnapshot.load())
|
||||
completion(Timeline(entries: [entry], policy: .after(midnight)))
|
||||
}
|
||||
}
|
||||
|
||||
struct NextTripWidgetView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
let entry: NextTripEntry
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.widgetURL(entry.snapshot?.trip.flatMap { WidgetSnapshot.link($0.path) })
|
||||
.modifier(WidgetBackground())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if let snapshot = entry.snapshot, let trip = snapshot.trip {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(snapshot.today == nil ? snapshot.labels.nextTrip : snapshot.labels.today)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
Text(trip.title)
|
||||
.font(.headline)
|
||||
.lineLimit(2)
|
||||
if let today = snapshot.today {
|
||||
Text(today.dayLabel)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
if family != .systemSmall {
|
||||
stops(today.stops, nothing: snapshot.labels.nothingPlanned)
|
||||
}
|
||||
} else if let days = snapshot.daysUntilStart(from: entry.date), days > 0 {
|
||||
Spacer(minLength: 0)
|
||||
Text(days == 1 ? snapshot.labels.tomorrow : snapshot.labels.inDays.replacingOccurrences(of: "{n}", with: String(days)))
|
||||
.font(.title3.weight(.bold))
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("TREK").font(.headline)
|
||||
Text(entry.snapshot?.labels.noTrip ?? "")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func stops(_ stops: [WidgetSnapshot.Stop], nothing: String) -> some View {
|
||||
if stops.isEmpty {
|
||||
Text(nothing).font(.caption).foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(Array(stops.prefix(3).enumerated()), id: \.offset) { _, stop in
|
||||
HStack(spacing: 6) {
|
||||
Text(stop.time ?? "")
|
||||
.font(.caption2.monospacedDigit().weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(minWidth: 36, alignment: .leading)
|
||||
Text(stop.name).font(.caption).lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The system background on iOS 17 and later, the plain one before.
|
||||
struct WidgetBackground: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOSApplicationExtension 17.0, *) {
|
||||
content.containerBackground(.fill.tertiary, for: .widget)
|
||||
} else {
|
||||
content.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NextTripWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: "NextTrip", provider: NextTripProvider()) { entry in
|
||||
NextTripWidgetView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("TREK")
|
||||
.description("Your next trip, and today's plan while you travel.")
|
||||
.supportedFamilies([.systemSmall, .systemMedium])
|
||||
}
|
||||
}
|
||||
@@ -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,12 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
/// The TREK widgets: the next trip on the home screen, and the travel day as a
|
||||
/// Live Activity.
|
||||
@main
|
||||
struct TREKWidgetsBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
NextTripWidget()
|
||||
TripDayLiveActivity()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import ActivityKit
|
||||
import Foundation
|
||||
|
||||
/// The Live Activity for a travel day: which trip and day, and the next stop.
|
||||
/// Compiled into both the app (which starts and updates it, TrekShellPlugin)
|
||||
/// and the widget extension (which draws it, TripDayLiveActivity).
|
||||
@available(iOS 16.1, *)
|
||||
struct TripDayAttributes: ActivityAttributes {
|
||||
struct ContentState: Codable, Hashable {
|
||||
/// "Day 2 of 5", already in the user's language.
|
||||
var dayLabel: String
|
||||
/// The next stop, or the label for "all done" once the day's stops are behind.
|
||||
var nextName: String
|
||||
/// The next stop's time as the user formats it, if it has one.
|
||||
var nextTime: String?
|
||||
/// Stops behind, out of the day's stops.
|
||||
var done: Int
|
||||
var total: Int
|
||||
}
|
||||
|
||||
var tripTitle: String
|
||||
/// The app path a tap opens.
|
||||
var path: String
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import ActivityKit
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
/// A travel day on the lock screen and in the Dynamic Island: the next stop and
|
||||
/// its time, and how much of the day is behind.
|
||||
struct TripDayLiveActivity: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: TripDayAttributes.self) { context in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(context.attributes.tripTitle).font(.caption.weight(.semibold)).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(context.state.dayLabel).font(.caption.weight(.semibold)).foregroundStyle(.secondary)
|
||||
}
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Image(systemName: "mappin.circle.fill")
|
||||
Text(context.state.nextName).font(.headline).lineLimit(1)
|
||||
Spacer()
|
||||
if let time = context.state.nextTime {
|
||||
Text(time).font(.headline.monospacedDigit())
|
||||
}
|
||||
}
|
||||
ProgressView(value: Double(context.state.done), total: Double(max(context.state.total, 1)))
|
||||
}
|
||||
.padding()
|
||||
.widgetURL(WidgetSnapshot.link(context.attributes.path))
|
||||
} dynamicIsland: { context in
|
||||
DynamicIsland {
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
Label(context.state.dayLabel, systemImage: "map").font(.caption)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
if let time = context.state.nextTime {
|
||||
Text(time).font(.caption.monospacedDigit())
|
||||
}
|
||||
}
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(context.state.nextName).font(.headline).lineLimit(1)
|
||||
ProgressView(value: Double(context.state.done), total: Double(max(context.state.total, 1)))
|
||||
}
|
||||
}
|
||||
} compactLeading: {
|
||||
Image(systemName: "mappin.circle.fill")
|
||||
} compactTrailing: {
|
||||
Text(context.state.nextTime ?? "\(context.state.done)/\(context.state.total)")
|
||||
.font(.caption2.monospacedDigit())
|
||||
} minimal: {
|
||||
Image(systemName: "mappin.circle.fill")
|
||||
}
|
||||
.widgetURL(WidgetSnapshot.link(context.attributes.path))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// What the app last told the widgets (native/widgets.ts writes it through
|
||||
/// TrekShell.setWidgetSnapshot into the keychain group the app and its
|
||||
/// extensions share). Texts arrive already translated, so the widget needs no
|
||||
/// localisation of its own and always speaks the language the app does.
|
||||
struct WidgetSnapshot: Codable {
|
||||
struct Trip: Codable {
|
||||
var title: String
|
||||
var start: String
|
||||
var end: String
|
||||
var path: String
|
||||
}
|
||||
|
||||
struct Stop: Codable, Hashable {
|
||||
var time: String?
|
||||
var name: String
|
||||
}
|
||||
|
||||
struct Today: Codable {
|
||||
var dayLabel: String
|
||||
var stops: [Stop]
|
||||
}
|
||||
|
||||
struct Labels: Codable {
|
||||
var nextTrip: String
|
||||
var today: String
|
||||
var noTrip: String
|
||||
/// Contains "{n}".
|
||||
var inDays: String
|
||||
var tomorrow: String
|
||||
var nothingPlanned: String
|
||||
}
|
||||
|
||||
var trip: Trip?
|
||||
var today: Today?
|
||||
var labels: Labels
|
||||
|
||||
static func load() -> WidgetSnapshot? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: "com.liketrek.trek.push",
|
||||
kSecAttrAccount as String: "widget.snapshot",
|
||||
kSecReturnData as String: true,
|
||||
]
|
||||
var result: CFTypeRef?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { return nil }
|
||||
return try? JSONDecoder().decode(WidgetSnapshot.self, from: data)
|
||||
}
|
||||
|
||||
/// Whole days from today to the trip's start, in the device's calendar.
|
||||
func daysUntilStart(from now: Date = Date()) -> Int? {
|
||||
guard let trip, let start = WidgetSnapshot.day(trip.start) else { return nil }
|
||||
let calendar = Calendar.current
|
||||
return calendar.dateComponents([.day], from: calendar.startOfDay(for: now), to: start).day
|
||||
}
|
||||
|
||||
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 app link a tap opens: com.liketrek.trek://navigate<path>.
|
||||
static func link(_ path: String) -> URL? {
|
||||
URL(string: "com.liketrek.trek://navigate\(path.hasPrefix("/") ? path : "/" + path)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
# Adds an app extension target to App.xcodeproj and embeds it in the app.
|
||||
#
|
||||
# ruby ios/scripts/add-extension.rb <name> <bundle-id> <type> [extra frameworks...]
|
||||
#
|
||||
# type is "notification" (Notification Service Extension) or "widget" (WidgetKit,
|
||||
# which also carries the Live Activities). The extension's sources live in
|
||||
# ios/App/<name>/ with an Info.plist and <name>.entitlements next to them. The
|
||||
# project file stays checked in, so the targets show in Xcode like any other;
|
||||
# this script is how they got there, and running it again changes nothing.
|
||||
require 'xcodeproj'
|
||||
|
||||
name, bundle_id, type, *frameworks = ARGV
|
||||
abort 'usage: add-extension.rb <name> <bundle-id> <notification|widget> [frameworks...]' unless name && bundle_id && type
|
||||
|
||||
project_path = File.expand_path('../App/App.xcodeproj', __dir__)
|
||||
project = Xcodeproj::Project.open(project_path)
|
||||
app = project.targets.find { |t| t.name == 'App' } or abort 'no App target'
|
||||
|
||||
if project.targets.any? { |t| t.name == name }
|
||||
puts "#{name} is already in the project"
|
||||
exit 0
|
||||
end
|
||||
|
||||
target = project.new_target(:app_extension, name, :ios, '15.0', nil, :swift)
|
||||
# new_target links Foundation by a path into one SDK version; it is linked
|
||||
# implicitly anyway, and a pinned SDK path breaks on the next Xcode.
|
||||
target.frameworks_build_phase.files.select { |f| f.display_name.to_s.include?('Foundation') }.each do |f|
|
||||
ref = f.file_ref
|
||||
f.remove_from_project
|
||||
ref&.remove_from_project
|
||||
end
|
||||
|
||||
group = project.main_group.find_subpath(name, true)
|
||||
group.set_source_tree('<group>')
|
||||
group.set_path(name)
|
||||
sources = Dir.children(File.expand_path("../App/#{name}", __dir__)).sort
|
||||
sources.each do |file|
|
||||
ref = group.new_reference(file)
|
||||
target.add_file_references([ref]) if file.end_with?('.swift')
|
||||
end
|
||||
|
||||
frameworks.each do |framework|
|
||||
ref = project.frameworks_group.new_file("System/Library/Frameworks/#{framework}.framework", :sdk_root)
|
||||
target.frameworks_build_phase.add_file_reference(ref, true)
|
||||
end
|
||||
|
||||
app_settings = app.build_configurations.first.build_settings
|
||||
target.build_configurations.each do |config|
|
||||
s = config.build_settings
|
||||
s['PRODUCT_BUNDLE_IDENTIFIER'] = bundle_id
|
||||
s['PRODUCT_NAME'] = '$(TARGET_NAME)'
|
||||
s['INFOPLIST_FILE'] = "#{name}/Info.plist"
|
||||
s['GENERATE_INFOPLIST_FILE'] = 'NO'
|
||||
s['CODE_SIGN_ENTITLEMENTS'] = "#{name}/#{name}.entitlements"
|
||||
s['CODE_SIGN_STYLE'] = 'Automatic'
|
||||
s['DEVELOPMENT_TEAM'] = app_settings['DEVELOPMENT_TEAM'] if app_settings['DEVELOPMENT_TEAM']
|
||||
s['SWIFT_VERSION'] = '5.0'
|
||||
s['TARGETED_DEVICE_FAMILY'] = '1,2'
|
||||
s['IPHONEOS_DEPLOYMENT_TARGET'] = type == 'widget' ? '16.2' : '15.0'
|
||||
s['MARKETING_VERSION'] = app_settings['MARKETING_VERSION'] || '1.0'
|
||||
s['CURRENT_PROJECT_VERSION'] = app_settings['CURRENT_PROJECT_VERSION'] || '1'
|
||||
s['SKIP_INSTALL'] = 'YES'
|
||||
s['LD_RUNPATH_SEARCH_PATHS'] = ['$(inherited)', '@executable_path/Frameworks', '@executable_path/../../Frameworks']
|
||||
end
|
||||
|
||||
embed = app.copy_files_build_phases.find { |p| p.name == 'Embed Foundation Extensions' } ||
|
||||
app.new_copy_files_build_phase('Embed Foundation Extensions')
|
||||
embed.symbol_dst_subfolder_spec = :plug_ins
|
||||
build_file = embed.add_file_reference(target.product_reference, true)
|
||||
build_file.settings = { 'ATTRIBUTES' => ['RemoveHeadersOnCopy'] }
|
||||
app.add_dependency(target)
|
||||
|
||||
project.save
|
||||
puts "added #{name} (#{bundle_id})"
|
||||
@@ -0,0 +1,24 @@
|
||||
# Compiles a source file of an extension into the App target too.
|
||||
#
|
||||
# ruby ios/scripts/share-with-app.rb <extension-group> <file.swift>
|
||||
#
|
||||
# For types both sides need: the Live Activity's attributes are started by the
|
||||
# app and drawn by the widget extension, so both must know them. Running it
|
||||
# again changes nothing.
|
||||
require 'xcodeproj'
|
||||
|
||||
group_name, file = ARGV
|
||||
abort 'usage: share-with-app.rb <extension-group> <file.swift>' unless group_name && file
|
||||
|
||||
project = Xcodeproj::Project.open(File.expand_path('../App/App.xcodeproj', __dir__))
|
||||
app = project.targets.find { |t| t.name == 'App' } or abort 'no App target'
|
||||
group = project.main_group[group_name] or abort "no group #{group_name}"
|
||||
ref = group.files.find { |f| f.path == file } or abort "no #{file} in #{group_name}"
|
||||
|
||||
if app.source_build_phase.files_references.include?(ref)
|
||||
puts "#{file} is already in App"
|
||||
else
|
||||
app.add_file_references([ref])
|
||||
project.save
|
||||
puts "#{file} now compiles into App too"
|
||||
end
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
+62
-23
@@ -3,6 +3,8 @@ import { Haptics, ImpactStyle, NotificationType } from '@capacitor/haptics';
|
||||
import { SplashScreen } from '@capacitor/splash-screen';
|
||||
import { StatusBar, Style } from '@capacitor/status-bar';
|
||||
import logoUrl from '../public/icons/icon-white.svg';
|
||||
import logoBlack from '../public/logo-dark.svg'; // #000 wordmark, for light backgrounds
|
||||
import logoWhite from '../public/logo-light.svg'; // #fff wordmark, for dark backgrounds
|
||||
import { getIapClient, productIdFor, type Purchase } from '../src/native/iap';
|
||||
import { CLOUD_URL, PRIVACY_URL, SERVER_DOCS_URL } from '../src/native/links';
|
||||
import { probeDemo, probeServer, type ProbeFailure, type ProbeGet } from '../src/native/serverAddress';
|
||||
@@ -147,11 +149,17 @@ function topbar(onBack: () => void): HTMLElement {
|
||||
|
||||
// ── Welcome ─────────────────────────────────────────────────────────────────
|
||||
function showWelcome(opts: { back?: boolean } = {}): void {
|
||||
const hero = el('img', { src: logoUrl, alt: '', className: 'hero-mark' });
|
||||
// The full TREK wordmark, in the variant that reads on the current scheme.
|
||||
const hero = el(
|
||||
'span',
|
||||
{ className: 'hero-logo' },
|
||||
el('img', { src: logoWhite, alt: 'TREK', className: 'on-dark' }),
|
||||
el('img', { src: logoBlack, alt: '', className: 'on-light' })
|
||||
);
|
||||
|
||||
const connect = el(
|
||||
'button',
|
||||
{ type: 'button', className: 'btn' },
|
||||
{ type: 'button', className: 'btn btn-ghost' },
|
||||
el(
|
||||
'span',
|
||||
{ className: 'btn-row' },
|
||||
@@ -167,7 +175,7 @@ function showWelcome(opts: { back?: boolean } = {}): void {
|
||||
|
||||
const cloud = el(
|
||||
'button',
|
||||
{ type: 'button', className: 'btn btn-ghost' },
|
||||
{ type: 'button', className: 'btn' },
|
||||
el(
|
||||
'span',
|
||||
{ className: 'btn-row' },
|
||||
@@ -181,7 +189,7 @@ function showWelcome(opts: { back?: boolean } = {}): void {
|
||||
showCloud();
|
||||
});
|
||||
|
||||
const demo = el('button', { type: 'button', className: 'linkbtn' }, t('native.connect.tryDemo'));
|
||||
const demo = el('button', { type: 'button', className: 'demo-link' }, t('native.connect.tryDemo'));
|
||||
demo.addEventListener('click', () => runDemo(demo));
|
||||
|
||||
show(
|
||||
@@ -189,10 +197,10 @@ function showWelcome(opts: { back?: boolean } = {}): void {
|
||||
'div',
|
||||
{},
|
||||
hero,
|
||||
el('h1', { className: 'title' }, t('native.welcome.headline')),
|
||||
el('h1', { className: 'title brand-title' }, t('native.welcome.headline')),
|
||||
el('p', { className: 'sub' }, t('native.welcome.sub')),
|
||||
connect,
|
||||
cloud,
|
||||
connect,
|
||||
demo,
|
||||
footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))
|
||||
),
|
||||
@@ -248,9 +256,6 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
|
||||
fail(result.reason);
|
||||
});
|
||||
|
||||
const demo = el('button', { type: 'button', className: 'linkbtn' }, t('native.connect.tryDemo'));
|
||||
demo.addEventListener('click', () => runDemo(demo, error));
|
||||
|
||||
show(
|
||||
el(
|
||||
'div',
|
||||
@@ -259,7 +264,6 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
|
||||
el('h1', { className: 'title-sm' }, t('native.connect.title')),
|
||||
el('p', { className: 'sub' }, t('native.connect.hint')),
|
||||
form,
|
||||
demo,
|
||||
footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))
|
||||
),
|
||||
opts
|
||||
@@ -295,7 +299,13 @@ function showCloud(opts: { back?: boolean } = {}): void {
|
||||
'div',
|
||||
{},
|
||||
topbar(() => showWelcome({ back: true })),
|
||||
el('h1', { className: 'title-sm' }, t('native.cloud.title')),
|
||||
el(
|
||||
'div',
|
||||
{ className: 'cloud-wordmark', role: 'img', ariaLabel: t('native.cloud.title') },
|
||||
el('img', { src: logoWhite, alt: '', className: 'on-dark' }),
|
||||
el('img', { src: logoBlack, alt: '', className: 'on-light' }),
|
||||
el('span', { className: 'cloud-tag' }, 'cloud')
|
||||
),
|
||||
el('p', { className: 'sub' }, t('native.cloud.sub')),
|
||||
el(
|
||||
'div',
|
||||
@@ -326,15 +336,27 @@ function money(cents: number): string {
|
||||
return new Intl.NumberFormat(locale, { style: 'currency', currency: 'EUR' }).format(cents / 100);
|
||||
}
|
||||
|
||||
// A sample catalogue for the dev preview only: the cloud site is behind a gate
|
||||
// during development, so its /api/plans is unreachable from a local build. Never
|
||||
// used in a real build (see the import.meta.env.DEV guard below).
|
||||
const SAMPLE_PLANS: CloudPlan[] = [
|
||||
{ id: 'start', name: 'Start', monthlyCents: 599, yearlyCents: 5990, aiDocuments: 0, storageGb: 10 },
|
||||
{ id: 'plus', name: 'Plus', monthlyCents: 999, yearlyCents: 9990, aiDocuments: 100, storageGb: 50 },
|
||||
{ id: 'pro', name: 'Pro', monthlyCents: 1599, yearlyCents: 15990, aiDocuments: 500, storageGb: 200 },
|
||||
{ id: 'studio', name: 'Studio', monthlyCents: 2399, yearlyCents: 23990, aiDocuments: 2000, storageGb: 1000 },
|
||||
];
|
||||
|
||||
async function fetchPlans(): Promise<CloudPlan[] | null> {
|
||||
try {
|
||||
const res = await get(`${CLOUD_URL}/api/plans`);
|
||||
const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data;
|
||||
const plans = (data as { plans?: CloudPlan[] })?.plans;
|
||||
return Array.isArray(plans) && plans.length ? plans : null;
|
||||
if (Array.isArray(plans) && plans.length) return plans;
|
||||
} catch {
|
||||
return null;
|
||||
// Fall through to the preview fallback.
|
||||
}
|
||||
if (import.meta.env?.DEV) return SAMPLE_PLANS;
|
||||
return null;
|
||||
}
|
||||
|
||||
function subscribe(plan: CloudPlan, interval: 'month' | 'year'): void {
|
||||
@@ -352,6 +374,9 @@ function planCard(plan: CloudPlan, interval: 'month' | 'year', featured: boolean
|
||||
const price = interval === 'year' ? plan.yearlyCents : plan.monthlyCents;
|
||||
const per = interval === 'year' ? t('native.plans.perYear') : t('native.plans.perMonth');
|
||||
const feat = (text: string) => el('span', {}, icon(I.check, 16), text);
|
||||
// An absent feature (e.g. a plan with no AI documents): a muted dash, not a
|
||||
// green tick, so "none" never reads as a perk.
|
||||
const featOff = (text: string) => el('span', { className: 'off' }, icon('M5 12h14', 16), text);
|
||||
const sub = el('button', { type: 'button', className: 'btn' }, t('native.plans.subscribe'));
|
||||
sub.addEventListener('click', () => subscribe(plan, interval));
|
||||
return el(
|
||||
@@ -368,7 +393,9 @@ function planCard(plan: CloudPlan, interval: 'month' | 'year', featured: boolean
|
||||
{ className: 'plan-feats' },
|
||||
feat(t('native.plans.unlimited')),
|
||||
feat(t('native.plans.storage', { n: String(plan.storageGb) })),
|
||||
feat(plan.aiDocuments > 0 ? t('native.plans.aiDocs', { n: String(plan.aiDocuments) }) : t('native.plans.noAi'))
|
||||
plan.aiDocuments > 0
|
||||
? feat(t('native.plans.aiDocs', { n: String(plan.aiDocuments) }))
|
||||
: featOff(t('native.plans.noAi'))
|
||||
),
|
||||
sub
|
||||
);
|
||||
@@ -381,12 +408,7 @@ function showPlans(opts: { back?: boolean } = {}): void {
|
||||
const content = el('div', { className: 'loading' }, el('span', { className: 'spinner' }));
|
||||
|
||||
const monthBtn = el('button', { type: 'button' }, t('native.plans.monthly'));
|
||||
const yearBtn = el(
|
||||
'button',
|
||||
{ type: 'button' },
|
||||
t('native.plans.yearly'),
|
||||
el('span', { className: 'save' }, t('native.plans.save'))
|
||||
);
|
||||
const yearBtn = el('button', { type: 'button' }, t('native.plans.yearly'));
|
||||
const seg = el('div', { className: 'segmented' }, monthBtn, yearBtn);
|
||||
|
||||
const paintSeg = () => {
|
||||
@@ -397,7 +419,7 @@ function showPlans(opts: { back?: boolean } = {}): void {
|
||||
const list = plans;
|
||||
if (!list) return;
|
||||
content.className = 'plans';
|
||||
content.replaceChildren(...list.map((p, i) => planCard(p, interval, i === list.length - 1)));
|
||||
content.replaceChildren(...list.map((p) => planCard(p, interval, false)));
|
||||
};
|
||||
const choose = (next: 'month' | 'year') => {
|
||||
if (interval === next) return;
|
||||
@@ -494,6 +516,7 @@ function showClaim(plan: CloudPlan, interval: 'month' | 'year', opts: { back?: b
|
||||
autocapitalize: 'off',
|
||||
spellcheck: false,
|
||||
value: '',
|
||||
placeholder: t('native.claim.addressLabel'),
|
||||
});
|
||||
slugInput.setAttribute('inputmode', 'url');
|
||||
slugInput.setAttribute('autocomplete', 'off');
|
||||
@@ -507,7 +530,13 @@ function showClaim(plan: CloudPlan, interval: 'month' | 'year', opts: { back?: b
|
||||
);
|
||||
const slugNote = el('p', { className: 'hint' });
|
||||
|
||||
const emailInput = el('input', { type: 'email', value: '', autocapitalize: 'off', spellcheck: false });
|
||||
const emailInput = el('input', {
|
||||
type: 'email',
|
||||
value: '',
|
||||
autocapitalize: 'off',
|
||||
spellcheck: false,
|
||||
placeholder: t('native.claim.emailLabel'),
|
||||
});
|
||||
emailInput.setAttribute('inputmode', 'email');
|
||||
emailInput.setAttribute('autocomplete', 'email');
|
||||
emailInput.setAttribute('enterkeyhint', 'go');
|
||||
@@ -782,7 +811,17 @@ async function start(): Promise<void> {
|
||||
iap.isAvailable().then((ok) => {
|
||||
iapAvailable = ok;
|
||||
});
|
||||
const { url } = await TrekShell.getServer();
|
||||
let url: string | null;
|
||||
try {
|
||||
({ url } = await TrekShell.getServer());
|
||||
} catch {
|
||||
// No native shell under us (a web preview, or a simulator without the
|
||||
// plugin): there is no remembered server, so start at the welcome screen
|
||||
// rather than leaving a blank page.
|
||||
showWelcome();
|
||||
await SplashScreen.hide().catch(() => {});
|
||||
return;
|
||||
}
|
||||
if (!url) {
|
||||
showWelcome();
|
||||
} else {
|
||||
|
||||
+279
-27
@@ -4,6 +4,49 @@
|
||||
* background and never flashes. Everything past this screen is the UI the
|
||||
* chosen server delivers.
|
||||
*/
|
||||
|
||||
/*
|
||||
* TREK's own type, self-hosted (latin subset, bundled — the shell loads offline
|
||||
* and hotlinks nothing). Poppins is the display face across the app and
|
||||
* liketrek.com; MuseoModerno draws the "cloud" of the TREK Cloud wordmark.
|
||||
*/
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('./fonts/poppins-latin-500.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('./fonts/poppins-latin-600.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('./fonts/poppins-latin-700.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'MuseoModerno';
|
||||
font-style: normal;
|
||||
font-weight: 600 700;
|
||||
font-display: swap;
|
||||
src: url('./fonts/museomoderno-latin-600.woff2') format('woff2');
|
||||
}
|
||||
/* Italic, subset to the letters of "cloud" — the wordmark's badge, nothing else. */
|
||||
@font-face {
|
||||
font-family: 'MuseoModerno';
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('./fonts/museomoderno-italic-600.woff2') format('woff2');
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #09090b;
|
||||
--bg-2: #101013;
|
||||
@@ -18,6 +61,13 @@
|
||||
--accent-ink: #09090b;
|
||||
--danger: #f87171;
|
||||
--ok: #4ade80;
|
||||
/* The hairline the ambient grid is drawn in, and a travel-dusk wash for depth. */
|
||||
--grid: rgba(255, 255, 255, 0.055);
|
||||
--wash-1: rgba(129, 140, 248, 0.12);
|
||||
--wash-2: rgba(244, 114, 182, 0.08);
|
||||
--veil-top: rgba(9, 9, 11, 0.4);
|
||||
--veil-mid: rgba(9, 9, 11, 0.72);
|
||||
--font: 'Poppins', -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', Roboto, system-ui, sans-serif;
|
||||
--radius: 16px;
|
||||
--ease: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||
@@ -39,6 +89,11 @@
|
||||
--accent-ink: #ffffff;
|
||||
--danger: #dc2626;
|
||||
--ok: #16a34a;
|
||||
--grid: rgba(17, 24, 39, 0.075);
|
||||
--wash-1: rgba(79, 70, 229, 0.1);
|
||||
--wash-2: rgba(219, 39, 119, 0.07);
|
||||
--veil-top: rgba(255, 255, 255, 0.12);
|
||||
--veil-mid: rgba(255, 255, 255, 0.74);
|
||||
color-scheme: light;
|
||||
}
|
||||
.shell-logo,
|
||||
@@ -56,9 +111,11 @@ html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background: var(--bg);
|
||||
background:
|
||||
radial-gradient(90% 55% at 50% -12%, var(--wash-1), transparent 62%),
|
||||
radial-gradient(80% 50% at 50% 112%, var(--wash-2), transparent 64%), var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', Roboto, system-ui, sans-serif;
|
||||
font-family: var(--font);
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
@@ -66,14 +123,47 @@ body {
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
/* An ambient glow behind the content, so the flat dark gains a little depth. */
|
||||
/*
|
||||
* The backdrop, in two fixed layers behind the content. First a soft dusk wash
|
||||
* at top and bottom — a trace of the colour the app's photos carry, never loud
|
||||
* enough to read as decoration. Then a quiet grid that fades out through a
|
||||
* radial mask: depth, the way liketrek.com's hero carries it.
|
||||
*/
|
||||
/* The grid, strongest at the top-right and fading left and down — the half the
|
||||
photo leaves open, so the two meet in a soft seam down the middle. */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
inset: 0 0 auto;
|
||||
height: 440px;
|
||||
background-image:
|
||||
linear-gradient(var(--grid) 1px, transparent 1px), linear-gradient(90deg, var(--grid) 1px, transparent 1px);
|
||||
background-size: 54px 54px;
|
||||
-webkit-mask-image: radial-gradient(135% 95% at 100% 0%, #000 34%, transparent 80%);
|
||||
mask-image: radial-gradient(135% 95% at 100% 0%, #000 34%, transparent 80%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
/*
|
||||
* A travel photo as a top band (Kyoto at dusk, the same image liketrek.com uses
|
||||
* in its pricing section), under a dark gradient and faded out at the bottom so
|
||||
* the headline stays legible. Dark-first; in light mode it steps back to the
|
||||
* quiet grid instead, which reads better on a white page.
|
||||
*/
|
||||
/* The travel photo (Kyoto at dusk, as on liketrek.com), anchored left and faded
|
||||
out toward the right so it hands over to the grid, and down into the page.
|
||||
The veil over it takes the scheme's page colour: a dark dusk in dark mode, a
|
||||
bright morning haze in light mode, so the black wordmark still reads on it. */
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0 0 auto;
|
||||
height: 440px;
|
||||
background:
|
||||
radial-gradient(60% 50% at 50% -5%, color-mix(in srgb, var(--ink) 7%, transparent), transparent 70%),
|
||||
radial-gradient(50% 40% at 50% 110%, color-mix(in srgb, var(--ink) 4%, transparent), transparent 70%);
|
||||
linear-gradient(180deg, var(--veil-top) 0%, var(--veil-mid) 62%, var(--bg) 100%),
|
||||
url('./img/kyoto.webp') left 28% / cover no-repeat;
|
||||
-webkit-mask-image: linear-gradient(90deg, #000 0%, #000 40%, transparent 80%);
|
||||
mask-image: linear-gradient(90deg, #000 0%, #000 40%, transparent 80%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
@@ -213,11 +303,47 @@ body::before {
|
||||
|
||||
/* The big hero mark on the welcome screen, with a gentle breathing float. */
|
||||
.hero-mark {
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
margin-bottom: 18px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin-bottom: 20px;
|
||||
animation: floaty 6s ease-in-out infinite;
|
||||
filter: drop-shadow(0 10px 30px color-mix(in srgb, var(--ink) 18%, transparent));
|
||||
filter: drop-shadow(0 14px 36px color-mix(in srgb, var(--wash-1) 90%, transparent))
|
||||
drop-shadow(0 6px 16px color-mix(in srgb, var(--ink) 22%, transparent));
|
||||
}
|
||||
|
||||
/* The full TREK wordmark on the welcome screen, in the variant that reads on the scheme. */
|
||||
.hero-logo {
|
||||
display: block;
|
||||
margin-bottom: 22px;
|
||||
filter: drop-shadow(0 14px 36px color-mix(in srgb, var(--wash-1) 90%, transparent))
|
||||
drop-shadow(0 6px 16px color-mix(in srgb, var(--ink) 22%, transparent));
|
||||
animation: logoRise 0.7s var(--ease) both;
|
||||
}
|
||||
.hero-logo img {
|
||||
display: block;
|
||||
height: 62px;
|
||||
width: auto;
|
||||
}
|
||||
.hero-logo .on-light {
|
||||
display: none;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
.hero-logo .on-dark {
|
||||
display: none;
|
||||
}
|
||||
.hero-logo .on-light {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@keyframes logoRise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@keyframes floaty {
|
||||
0%,
|
||||
@@ -231,24 +357,69 @@ body::before {
|
||||
|
||||
/* ── Type ────────────────────────────────────────────────────────────────── */
|
||||
.title {
|
||||
font-size: 1.9rem;
|
||||
line-height: 1.12;
|
||||
font-size: 2.05rem;
|
||||
line-height: 1.1;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
letter-spacing: -0.03em;
|
||||
margin: 0;
|
||||
/* Honour the newline in the headline string (e.g. "Deine Reisen.\nDein Plan."). */
|
||||
white-space: pre-line;
|
||||
}
|
||||
/* The welcome headline as the brand says it: MuseoModerno, lower case. */
|
||||
.brand-title {
|
||||
font-family: 'MuseoModerno', 'Poppins', sans-serif;
|
||||
font-weight: 700;
|
||||
text-transform: lowercase;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
/* The TREK Cloud lockup (cloud intro screen): the wordmark plus the "cloud"
|
||||
badge in italic MuseoModerno, an inverted pill, exactly as on liketrek.com. */
|
||||
.cloud-wordmark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
.cloud-wordmark img {
|
||||
display: block;
|
||||
height: 38px;
|
||||
width: auto;
|
||||
}
|
||||
.cloud-wordmark .on-light {
|
||||
display: none;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
.cloud-wordmark .on-dark {
|
||||
display: none;
|
||||
}
|
||||
.cloud-wordmark .on-light {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
.cloud-tag {
|
||||
font-family: 'MuseoModerno', system-ui, sans-serif;
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.35;
|
||||
padding: 2px 11px;
|
||||
border-radius: 999px;
|
||||
color: var(--bg);
|
||||
background: var(--ink);
|
||||
}
|
||||
.title-sm {
|
||||
font-size: 1.45rem;
|
||||
line-height: 1.2;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.18;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.015em;
|
||||
letter-spacing: -0.025em;
|
||||
margin: 0;
|
||||
}
|
||||
.sub {
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
line-height: 1.55;
|
||||
font-size: 1.02rem;
|
||||
font-size: 0.9rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@@ -263,10 +434,14 @@ body::before {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
cursor: pointer;
|
||||
box-shadow:
|
||||
0 8px 22px -14px rgba(0, 0, 0, 0.55),
|
||||
0 1px 2px rgba(0, 0, 0, 0.25);
|
||||
transition:
|
||||
transform 0.14s var(--ease),
|
||||
opacity 0.2s,
|
||||
background 0.2s,
|
||||
box-shadow 0.2s,
|
||||
border-color 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -284,6 +459,9 @@ body::before {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
border-color: var(--edge);
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.22),
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--ink) 4%, transparent);
|
||||
}
|
||||
.btn-ghost:active {
|
||||
background: var(--surface-2);
|
||||
@@ -318,6 +496,34 @@ body::before {
|
||||
align-self: center;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
/* "Try the demo": a tertiary action, but a tidy pill so it reads as a choice, not stray text. */
|
||||
.demo-link {
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
margin-top: 2px;
|
||||
padding: 9px 18px;
|
||||
min-height: 40px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--edge);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
color 0.2s,
|
||||
background 0.2s,
|
||||
border-color 0.2s,
|
||||
transform 0.14s var(--ease);
|
||||
}
|
||||
.demo-link:active {
|
||||
transform: scale(0.97);
|
||||
background: var(--surface-2);
|
||||
color: var(--ink);
|
||||
}
|
||||
.linkbtn:active {
|
||||
color: var(--ink);
|
||||
}
|
||||
@@ -329,14 +535,21 @@ a.linkbtn {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 2px 18px;
|
||||
gap: 4px 22px;
|
||||
margin-top: 6px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--edge);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
.foot-links a {
|
||||
color: var(--faint);
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
padding: 8px 2px;
|
||||
padding: 6px 2px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.foot-links a:active {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* ── Address form ────────────────────────────────────────────────────────── */
|
||||
@@ -351,13 +564,16 @@ a.linkbtn {
|
||||
.field input {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
font-size: 1.05rem;
|
||||
padding: 16px 18px;
|
||||
font-size: 1rem;
|
||||
padding: 13px 16px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--edge);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.18),
|
||||
inset 0 1px 0 color-mix(in srgb, var(--ink) 4%, transparent);
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.25s;
|
||||
@@ -574,10 +790,19 @@ a.linkbtn {
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
background: var(--surface);
|
||||
box-shadow:
|
||||
0 2px 10px -4px rgba(0, 0, 0, 0.3),
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--ink) 3%, transparent);
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s,
|
||||
transform 0.14s var(--ease);
|
||||
}
|
||||
.plan.featured {
|
||||
box-shadow:
|
||||
0 10px 30px -10px color-mix(in srgb, var(--wash-1) 85%, transparent),
|
||||
0 2px 8px -3px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.plan:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
@@ -604,7 +829,7 @@ a.linkbtn {
|
||||
font-weight: 500;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
margin-left: 3px;
|
||||
margin-left: 7px;
|
||||
}
|
||||
.plan-feats {
|
||||
margin: 10px 0 14px;
|
||||
@@ -623,6 +848,10 @@ a.linkbtn {
|
||||
flex: none;
|
||||
color: var(--ok);
|
||||
}
|
||||
.plan-feats .off,
|
||||
.plan-feats .off svg {
|
||||
color: var(--faint);
|
||||
}
|
||||
.plan .btn {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
@@ -654,21 +883,44 @@ a.linkbtn {
|
||||
}
|
||||
|
||||
/* ── Claim: address + email, and the provisioning wait ───────────────────── */
|
||||
/* One rounded field holding the address input and its fixed ".liketrek.com"
|
||||
suffix — the input itself is seamless, the box and its states live here. */
|
||||
.slug-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-right: 14px;
|
||||
padding-right: 16px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--edge);
|
||||
background: var(--surface);
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.18),
|
||||
inset 0 1px 0 color-mix(in srgb, var(--ink) 4%, transparent);
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.25s;
|
||||
}
|
||||
.slug-field .slug-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: right;
|
||||
text-align: left;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.slug-field:focus-within {
|
||||
border-color: var(--edge-strong);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--ink) 9%, transparent);
|
||||
}
|
||||
.slug-field.bad {
|
||||
border-color: var(--danger);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 16%, transparent);
|
||||
animation: shake 0.3s;
|
||||
}
|
||||
.slug-suffix {
|
||||
flex: none;
|
||||
color: var(--muted);
|
||||
font-size: 0.98rem;
|
||||
padding-left: 2px;
|
||||
font-size: 0.95rem;
|
||||
padding-left: 4px;
|
||||
user-select: none;
|
||||
}
|
||||
.hint {
|
||||
|
||||
@@ -125,7 +125,6 @@
|
||||
"components/shared/CustomSelect.tsx": 1,
|
||||
"components/shared/CustomTimePicker.tsx": 1,
|
||||
"components/shared/EmptyState.tsx": 1,
|
||||
"components/shared/ErrorBoundary.tsx": 2,
|
||||
"components/shared/Modal.tsx": 3,
|
||||
"components/shared/NavigationMenu.tsx": 1,
|
||||
"components/shared/NoteColorPicker.tsx": 2,
|
||||
@@ -166,32 +165,32 @@
|
||||
"mobile/screens/admin/MAdminUi.tsx": 10,
|
||||
"mobile/screens/admin/MAdminUsersSection.tsx": 14,
|
||||
"mobile/screens/admin/MApiProviderBlocks.tsx": 10,
|
||||
"mobile/screens/atlas/MAtlas.tsx": 7,
|
||||
"mobile/screens/atlas/MAtlasBucketSheet.tsx": 16,
|
||||
"mobile/screens/atlas/MAtlas.tsx": 5,
|
||||
"mobile/screens/atlas/MAtlasBucketSheet.tsx": 14,
|
||||
"mobile/screens/atlas/MAtlasCountryPopup.tsx": 11,
|
||||
"mobile/screens/atlas/MAtlasSearch.tsx": 5,
|
||||
"mobile/screens/atlas/MAtlasStatsCard.tsx": 3,
|
||||
"mobile/screens/collections/MCollAddSheet.tsx": 7,
|
||||
"mobile/screens/collections/MCollAddSheet.tsx": 6,
|
||||
"mobile/screens/collections/MCollCategoryPicker.tsx": 2,
|
||||
"mobile/screens/collections/MCollEditSheet.tsx": 5,
|
||||
"mobile/screens/collections/MCollEditSheet.tsx": 2,
|
||||
"mobile/screens/collections/MCollLabelsSheet.tsx": 6,
|
||||
"mobile/screens/collections/MCollLinksEditor.tsx": 2,
|
||||
"mobile/screens/collections/MCollListPickerSheet.tsx": 3,
|
||||
"mobile/screens/collections/MCollPlaceRow.tsx": 3,
|
||||
"mobile/screens/collections/MCollPlaceSheet.tsx": 11,
|
||||
"mobile/screens/collections/MCollShareSheet.tsx": 14,
|
||||
"mobile/screens/collections/MCollSheetKit.tsx": 6,
|
||||
"mobile/screens/collections/MCollSheetKit.tsx": 5,
|
||||
"mobile/screens/collections/MCollTripPickerSheet.tsx": 4,
|
||||
"mobile/screens/collections/MCollections.tsx": 21,
|
||||
"mobile/screens/collections/MCollections.tsx": 18,
|
||||
"mobile/screens/dashboard/MDashWidgets.tsx": 22,
|
||||
"mobile/screens/dashboard/MDashboard.tsx": 36,
|
||||
"mobile/screens/dashboard/MNewTripSheet.tsx": 20,
|
||||
"mobile/screens/dashboard/MDashboard.tsx": 31,
|
||||
"mobile/screens/dashboard/MNewTripSheet.tsx": 12,
|
||||
"mobile/screens/dashboard/MUserMenu.tsx": 7,
|
||||
"mobile/screens/journey/MJourney.tsx": 10,
|
||||
"mobile/screens/journey/MJourneyCreateSheet.tsx": 10,
|
||||
"mobile/screens/journey/MJourneyCreateSheet.tsx": 7,
|
||||
"mobile/screens/journey/MJourneyDetail.tsx": 6,
|
||||
"mobile/screens/journey/MJourneyEntrySheet.tsx": 52,
|
||||
"mobile/screens/journey/MJourneySettingsSheet.tsx": 32,
|
||||
"mobile/screens/journey/MJourneyEntrySheet.tsx": 48,
|
||||
"mobile/screens/journey/MJourneySettingsSheet.tsx": 2,
|
||||
"mobile/screens/journey/mobileJourneyMeta.ts": 4,
|
||||
"mobile/screens/notifications/MNotificationRow.tsx": 4,
|
||||
"mobile/screens/notifications/MNotifications.tsx": 3,
|
||||
@@ -202,13 +201,13 @@
|
||||
"mobile/screens/settings/MMobileNavCustomizer.tsx": 8,
|
||||
"mobile/screens/settings/MPhotoProvidersSection.tsx": 2,
|
||||
"mobile/screens/settings/MScopeGroupPicker.tsx": 5,
|
||||
"mobile/screens/settings/MSetPickerSheet.tsx": 2,
|
||||
"mobile/screens/settings/MSetPickerSheet.tsx": 1,
|
||||
"mobile/screens/settings/MSettings.tsx": 2,
|
||||
"mobile/screens/settings/MSettingsAbout.tsx": 5,
|
||||
"mobile/screens/settings/MSettingsAccount.tsx": 20,
|
||||
"mobile/screens/settings/MSettingsAppearance.tsx": 12,
|
||||
"mobile/screens/settings/MSettingsMap.tsx": 5,
|
||||
"mobile/screens/settings/MSettingsMcp.tsx": 30,
|
||||
"mobile/screens/settings/MSettingsMcp.tsx": 26,
|
||||
"mobile/screens/settings/MSettingsNotifications.tsx": 5,
|
||||
"mobile/screens/settings/MSettingsOffline.tsx": 18,
|
||||
"mobile/screens/settings/MSettingsPlugins.tsx": 14,
|
||||
@@ -217,13 +216,12 @@
|
||||
"mobile/screens/trip/MTripLoadingSplash.tsx": 2,
|
||||
"mobile/screens/trip/MTripShell.tsx": 9,
|
||||
"mobile/screens/trip/places/MPlacesBrowser.tsx": 10,
|
||||
"mobile/screens/trip/places/MPlacesBulkCategorySheet.tsx": 4,
|
||||
"mobile/screens/trip/places/MPlacesBulkCategorySheet.tsx": 2,
|
||||
"mobile/screens/trip/places/MPlacesFilterControls.tsx": 1,
|
||||
"mobile/screens/trip/places/MPlacesFilterSheet.tsx": 3,
|
||||
"mobile/screens/trip/places/MPlacesSaveToCollectionSheet.tsx": 7,
|
||||
"mobile/screens/trip/places/MPlacesSaveToCollectionSheet.tsx": 6,
|
||||
"mobile/screens/trip/places/MPlacesToursModeSwitch.tsx": 1,
|
||||
"mobile/screens/trip/plan/MPlanTimeline.tsx": 14,
|
||||
"mobile/screens/trip/plan/MPlanTimelineRows.tsx": 26,
|
||||
"mobile/screens/trip/plan/MPlanTimeline.tsx": 12,
|
||||
"mobile/screens/trip/plan/MPlanTimelineRows.tsx": 24,
|
||||
"mobile/screens/trip/roadtrip/MRoadtripRows.tsx": 31,
|
||||
"mobile/screens/trip/roadtrip/MRoadtripTab.tsx": 16,
|
||||
"mobile/screens/trip/roadtrip/MRtAlternativesBar.tsx": 8,
|
||||
@@ -237,8 +235,8 @@
|
||||
"mobile/screens/trip/sheets/ImpFileStep.tsx": 12,
|
||||
"mobile/screens/trip/sheets/ImpListStep.tsx": 3,
|
||||
"mobile/screens/trip/sheets/MAccommodationSheet.tsx": 10,
|
||||
"mobile/screens/trip/sheets/MBookingFields.tsx": 6,
|
||||
"mobile/screens/trip/sheets/MBrowseActionsSheet.tsx": 5,
|
||||
"mobile/screens/trip/sheets/MBookingFields.tsx": 5,
|
||||
"mobile/screens/trip/sheets/MBrowseActionsSheet.tsx": 3,
|
||||
"mobile/screens/trip/sheets/MCostSheet.tsx": 33,
|
||||
"mobile/screens/trip/sheets/MDaySheet.tsx": 22,
|
||||
"mobile/screens/trip/sheets/MDaysSheet.tsx": 4,
|
||||
@@ -253,11 +251,10 @@
|
||||
"mobile/screens/trip/sheets/MTransportFormSheet.tsx": 9,
|
||||
"mobile/screens/trip/sheets/MTransportSheet.tsx": 12,
|
||||
"mobile/screens/trip/sheets/MTripSheetUi.tsx": 5,
|
||||
"mobile/screens/trip/sheets/MTripSheets.tsx": 2,
|
||||
"mobile/screens/trip/sheets/PlCategoryPicker.tsx": 2,
|
||||
"mobile/screens/trip/sheets/PlFileAttach.tsx": 7,
|
||||
"mobile/screens/trip/sheets/PlPlaceSearch.tsx": 6,
|
||||
"mobile/screens/trip/sheets/PlSheetChrome.tsx": 7,
|
||||
"mobile/screens/trip/sheets/PlSheetChrome.tsx": 5,
|
||||
"mobile/screens/trip/sheets/PlTimeFields.tsx": 3,
|
||||
"mobile/screens/trip/tabs/MBagsSheet.tsx": 13,
|
||||
"mobile/screens/trip/tabs/MBookingsTab.tsx": 2,
|
||||
@@ -281,11 +278,11 @@
|
||||
"mobile/screens/trip/tabs/MTransportsTab.tsx": 2,
|
||||
"mobile/screens/trip/tabs/filesModel.ts": 5,
|
||||
"mobile/screens/trip/tabs/tabChrome.tsx": 12,
|
||||
"mobile/screens/vacay/MVacay.tsx": 23,
|
||||
"mobile/screens/vacay/MVacayInviteSheet.tsx": 9,
|
||||
"mobile/screens/vacay/MVacay.tsx": 17,
|
||||
"mobile/screens/vacay/MVacayInviteSheet.tsx": 8,
|
||||
"mobile/screens/vacay/MVacayMonth.tsx": 3,
|
||||
"mobile/screens/vacay/MVacaySettingsSheet.tsx": 35,
|
||||
"mobile/screens/vacay/MVacayShareSheet.tsx": 11,
|
||||
"mobile/screens/vacay/MVacaySettingsSheet.tsx": 34,
|
||||
"mobile/screens/vacay/MVacayShareSheet.tsx": 10,
|
||||
"mobile/screens/vacay/vacayDayModel.ts": 1,
|
||||
"pages/AtlasPage.tsx": 32,
|
||||
"pages/DashboardPage.tsx": 1,
|
||||
|
||||
+14
-2
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState, useRef, ReactNode, Suspense } from 'react'
|
||||
import { Routes, Route, Navigate, useLocation } from 'react-router'
|
||||
import { Routes, Route, Navigate, useLocation, useNavigationType } from 'react-router'
|
||||
import { useAuthStore } from './store/authStore'
|
||||
import { useSettingsStore } from './store/settingsStore'
|
||||
import { applyAppearance } from './theme/applyAppearance'
|
||||
@@ -21,6 +21,7 @@ import { lazyWithRetry } from './utils/lazyWithRetry'
|
||||
import { reconcileAppVersion } from './utils/versionHandover'
|
||||
import { useServerVersionStore } from './store/serverVersionStore'
|
||||
import { useIsPhone } from './mobile/useIsPhone'
|
||||
import { useRouteTransition } from './mobile/routeTransition'
|
||||
import { TranslationProvider, useTranslation } from './i18n'
|
||||
import { authApi, isAuthPublicPath } from './api/client'
|
||||
import { tripRepo } from './repo/tripRepo'
|
||||
@@ -32,6 +33,10 @@ import { useRoadtripPreferencesSync } from './hooks/useRoadtripPreferencesSync'
|
||||
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers'
|
||||
import OfflineBanner from './components/Layout/OfflineBanner'
|
||||
import NewVersionNotice from './components/Layout/NewVersionNotice'
|
||||
import NativeUpdateNotice from './components/Layout/NativeUpdateNotice'
|
||||
import AppLockGate from './components/Layout/AppLockGate'
|
||||
import NativeImportSheet from './components/Layout/NativeImportSheet'
|
||||
import NativeWidgetsSync from './components/Layout/NativeWidgetsSync'
|
||||
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
|
||||
import HelpPanel from './components/Help/HelpPanel'
|
||||
// Notice action registrations (side-effect imports):
|
||||
@@ -407,6 +412,9 @@ export default function App() {
|
||||
}, [settings.dark_mode, settings.appearance, isSharedPage])
|
||||
|
||||
const isPhone = useIsPhone()
|
||||
// On the phone, pages slide like a native navigation stack (mobile/routeTransition.ts).
|
||||
const navigationType = useNavigationType()
|
||||
const routedLocation = useRouteTransition(location, navigationType, isPhone)
|
||||
const keepingTour = useSaveToCollectionStore(s => s.tour !== null)
|
||||
const isAuthPage = location.pathname.startsWith('/login')
|
||||
|| location.pathname.startsWith('/register')
|
||||
@@ -442,12 +450,16 @@ export default function App() {
|
||||
)}
|
||||
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
|
||||
<ErrorBoundary boundaryId="widget:new-version" fallback={null}><NewVersionNotice /></ErrorBoundary>
|
||||
<ErrorBoundary boundaryId="widget:native-update" fallback={null}><NativeUpdateNotice /></ErrorBoundary>
|
||||
<ErrorBoundary boundaryId="widget:app-lock" fallback={null}><AppLockGate /></ErrorBoundary>
|
||||
<ErrorBoundary boundaryId="widget:native-import" fallback={null}><NativeImportSheet /></ErrorBoundary>
|
||||
<ErrorBoundary boundaryId="widget:native-widgets" fallback={null}><NativeWidgetsSync /></ErrorBoundary>
|
||||
{/* One boundary for all route chunks, above <Routes> so it stays mounted
|
||||
across navigations. react-router runs location updates inside a transition,
|
||||
so a mounted boundary keeps the current page on screen instead of flashing
|
||||
a spinner on every jump — the spinner is for the first paint of a deep link. */}
|
||||
<Suspense fallback={<RouteFallback />}>
|
||||
<Routes>
|
||||
<Routes location={routedLocation}>
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
<Route path="/login" element={<PublicRoute redirectAuthed><LoginPage /></PublicRoute>} />
|
||||
<Route path="/shared/:token" element={<PublicRoute><SharedTripPage /></PublicRoute>} />
|
||||
|
||||
@@ -12,6 +12,8 @@ const { probeNow } = vi.hoisted(() => ({
|
||||
probeNow: vi.fn(async (): Promise<'online' | 'offline' | 'proxy-wall'> => 'offline'),
|
||||
}))
|
||||
vi.mock('../sync/connectivity', () => ({ probeNow }))
|
||||
const isNativeApp = vi.hoisted(() => vi.fn(() => false))
|
||||
vi.mock('../native/platform', () => ({ isNativeApp }))
|
||||
|
||||
const { apiClient, adminApi, mapsApi, pluginsApi, parseInDev } = await import('./client')
|
||||
|
||||
@@ -357,6 +359,24 @@ describe('client > redirect handling', () => {
|
||||
expect(loc.href).toBe('/login?redirect=' + encodeURIComponent('/trips/7?tab=map#day-2'))
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-019b: in the app it waits for the first page to finish loading before it leaves', async () => {
|
||||
isNativeApp.mockReturnValue(true)
|
||||
const ready = Object.getOwnPropertyDescriptor(Document.prototype, 'readyState')
|
||||
Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true })
|
||||
const loc = setLocation('/dashboard')
|
||||
server.use(http.get('/api/auth/me', () => HttpResponse.json({ code: 'AUTH_REQUIRED' }, { status: 401 })))
|
||||
|
||||
await captureError(() => apiClient.get('/auth/me'))
|
||||
expect(loc.href).not.toContain('/login')
|
||||
|
||||
window.dispatchEvent(new Event('load'))
|
||||
expect(loc.href).toBe('/login?redirect=' + encodeURIComponent('/dashboard'))
|
||||
|
||||
delete (document as { readyState?: unknown }).readyState
|
||||
if (ready) Object.defineProperty(Document.prototype, 'readyState', ready)
|
||||
isNativeApp.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-020: an MFA_REQUIRED 403 sends the user to the settings page', async () => {
|
||||
const loc = setLocation('/dashboard')
|
||||
server.use(http.get('/api/auth/me', () => HttpResponse.json({ code: 'MFA_REQUIRED' }, { status: 403 })))
|
||||
|
||||
@@ -77,6 +77,7 @@ import { getSocketId } from './websocket'
|
||||
import { probeNow } from '../sync/connectivity'
|
||||
import { downloadBlob } from '../utils/fileDownload'
|
||||
import { translateRateLimit } from './rateLimitMessage'
|
||||
import { isNativeApp } from '../native/platform'
|
||||
|
||||
/**
|
||||
* Validate a response payload against its @trek/shared Zod schema — but only in
|
||||
@@ -150,6 +151,20 @@ export function isAuthPublicPath(pathname: string): boolean {
|
||||
return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p))
|
||||
}
|
||||
|
||||
/**
|
||||
* A full page load to `url`. In the apps it waits for the first page to finish
|
||||
* loading: started earlier, it cancels that load, and the shell answers a
|
||||
* cancelled load with its "Server not reachable" page, which is where a
|
||||
* signed-out user opening the app for the first time ended up.
|
||||
*/
|
||||
function hardNavigate(url: string): void {
|
||||
const go = () => {
|
||||
window.location.href = url
|
||||
}
|
||||
if (isNativeApp() && document.readyState !== 'complete') window.addEventListener('load', go, { once: true })
|
||||
else go()
|
||||
}
|
||||
|
||||
// Unregisters the SW before reloading so the navigation reaches the network.
|
||||
// Without this, WorkBox's NavigationRoute serves the cached SPA shell and the
|
||||
// upstream proxy (CF Access / Pangolin) never gets to challenge the user.
|
||||
@@ -218,7 +233,7 @@ apiClient.interceptors.response.use(
|
||||
const { pathname } = window.location
|
||||
if (!isAuthPublicPath(pathname)) {
|
||||
const currentPath = pathname + window.location.search + window.location.hash
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
||||
hardNavigate('/login?redirect=' + encodeURIComponent(currentPath))
|
||||
}
|
||||
}
|
||||
if (
|
||||
@@ -226,7 +241,7 @@ apiClient.interceptors.response.use(
|
||||
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
|
||||
!window.location.pathname.startsWith('/settings')
|
||||
) {
|
||||
window.location.href = '/settings?mfa=required'
|
||||
hardNavigate('/settings?mfa=required')
|
||||
}
|
||||
if (error.response?.status === 429) {
|
||||
const translated = translateRateLimit()
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function MSaveToCollectionSheet() {
|
||||
{/* Header — mirrors the place detail sheet: icon + title + target name + close */}
|
||||
<div className="flex-none px-[18px] pt-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex h-[42px] w-[42px] flex-none items-center justify-center rounded-[14px] bg-[color:var(--m-ic)] text-m-muted">
|
||||
<span className="flex h-10 w-10 flex-none items-center justify-center rounded-[13px] bg-[color:var(--m-ic)] text-m-ink">
|
||||
<Bookmark size={18} strokeWidth={2} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
|
||||
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from '../Map/glProviders'
|
||||
import { buildGlMap, initialMapStyle } from '../Map/glSupport'
|
||||
import { mapboxTileCacheRequest, tileCacheActive } from '../../native/tileCache'
|
||||
import type { JourneyTrack } from '@trek/shared'
|
||||
import { SHOT_INDEX_ATTR, ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup'
|
||||
import { clusterPhotos, photoMarkerHtml, type MapPhoto } from './journeyPhotoLayer'
|
||||
@@ -395,6 +396,9 @@ function JourneyMapGL(
|
||||
antialias: mapboxQuality,
|
||||
}
|
||||
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
|
||||
// In the iOS app, Mapbox's own resources go through the app's tile store, so
|
||||
// what was viewed stays on the device for up to 30 days (native/tileCache.ts).
|
||||
if (!isMapLibre && tileCacheActive()) mapOptions.transformRequest = mapboxTileCacheRequest
|
||||
// MapLibre's around-center mouse rotate ping-pongs near mid-screen (#1545)
|
||||
// — see MapViewGL. Keep the plain dx-based rotate everywhere.
|
||||
if (isMapLibre) mapOptions.aroundCenter = false
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { App } from '@capacitor/app';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { isAppLockEnabled, unlockApp } from '../../native/appLock';
|
||||
import AppLockGate from './AppLockGate';
|
||||
|
||||
vi.mock('@capacitor/app', () => ({ App: { addListener: vi.fn(() => Promise.resolve({ remove: vi.fn() })) } }));
|
||||
vi.mock('../../native/appLock', () => ({
|
||||
RELOCK_AFTER_MS: 30_000,
|
||||
isAppLockEnabled: vi.fn(),
|
||||
unlockApp: vi.fn(),
|
||||
}));
|
||||
|
||||
function stateListener(): (state: { isActive: boolean }) => void {
|
||||
const call = vi.mocked(App.addListener).mock.calls.find(([name]) => name === 'appStateChange');
|
||||
if (!call) throw new Error('no appStateChange listener');
|
||||
return call[1] as unknown as (state: { isActive: boolean }) => void;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(isAppLockEnabled).mockReturnValue(true);
|
||||
vi.mocked(unlockApp).mockResolvedValue(false);
|
||||
});
|
||||
|
||||
describe('AppLockGate', () => {
|
||||
it('covers the app at launch and asks at once; a successful unlock lifts it', async () => {
|
||||
render(<AppLockGate />);
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
await waitFor(() => expect(unlockApp).toHaveBeenCalledTimes(1));
|
||||
|
||||
vi.mocked(unlockApp).mockResolvedValue(true);
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('locks again after half a minute in the background, not after a glance away', async () => {
|
||||
vi.mocked(unlockApp).mockResolvedValue(true);
|
||||
const now = vi.spyOn(Date, 'now');
|
||||
render(<AppLockGate />);
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
|
||||
vi.mocked(unlockApp).mockResolvedValue(false);
|
||||
now.mockReturnValue(1_000);
|
||||
act(() => stateListener()({ isActive: false }));
|
||||
now.mockReturnValue(10_000);
|
||||
act(() => stateListener()({ isActive: true }));
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
|
||||
act(() => stateListener()({ isActive: false }));
|
||||
now.mockReturnValue(60_000);
|
||||
act(() => stateListener()({ isActive: true }));
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
now.mockRestore();
|
||||
});
|
||||
|
||||
it('is nothing while the lock is off', () => {
|
||||
vi.mocked(isAppLockEnabled).mockReturnValue(false);
|
||||
const { container } = render(<AppLockGate />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
expect(unlockApp).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* AppLockGate: covers TREK until Face ID, Touch ID or the passcode unlocks it,
|
||||
* when the app lock is on (native/appLock.ts). It locks at launch and again
|
||||
* after the app spent more than half a minute in the background, and asks at
|
||||
* once rather than waiting for a tap; the button is there for a second try.
|
||||
* Rendered once in App; outside the app, or with the lock off, it is nothing.
|
||||
*/
|
||||
import { App } from '@capacitor/app';
|
||||
import { Lock } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { RELOCK_AFTER_MS, isAppLockEnabled, unlockApp } from '../../native/appLock';
|
||||
import { useNativeCapabilities } from '../../native/capabilities';
|
||||
|
||||
export default function AppLockGate(): React.ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
// Re-read once the binary has said what it can do.
|
||||
const { caps } = useNativeCapabilities();
|
||||
const [locked, setLocked] = useState(false);
|
||||
const asking = useRef(false);
|
||||
const backgroundSince = useRef<number | null>(null);
|
||||
|
||||
const ask = useCallback(async () => {
|
||||
if (asking.current) return;
|
||||
asking.current = true;
|
||||
const ok = await unlockApp(t('native.lock.reason'));
|
||||
asking.current = false;
|
||||
if (ok) setLocked(false);
|
||||
}, [t]);
|
||||
|
||||
// At launch, once the capabilities are known.
|
||||
useEffect(() => {
|
||||
if (!isAppLockEnabled()) return;
|
||||
setLocked(true);
|
||||
void ask();
|
||||
}, [caps, ask]);
|
||||
|
||||
// Back from the background after a while.
|
||||
useEffect(() => {
|
||||
if (!isAppLockEnabled()) return;
|
||||
const listener = App.addListener('appStateChange', ({ isActive }) => {
|
||||
if (!isActive) {
|
||||
backgroundSince.current = Date.now();
|
||||
return;
|
||||
}
|
||||
const since = backgroundSince.current;
|
||||
backgroundSince.current = null;
|
||||
if (since != null && Date.now() - since > RELOCK_AFTER_MS) {
|
||||
setLocked(true);
|
||||
void ask();
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
void listener.then((handle) => handle.remove());
|
||||
};
|
||||
}, [caps, ask]);
|
||||
|
||||
if (!locked) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('native.lock.title')}
|
||||
className="fixed inset-0 z-[calc(var(--z-toast)+1)] flex flex-col items-center justify-center gap-5 bg-surface p-8 text-center backdrop-blur-2xl"
|
||||
>
|
||||
<span className="flex h-16 w-16 items-center justify-center rounded-full bg-surface-card text-content shadow-lg">
|
||||
<Lock size={26} strokeWidth={2} aria-hidden="true" />
|
||||
</span>
|
||||
<p className="text-title font-semibold text-content">{t('native.lock.title')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void ask()}
|
||||
className="rounded-full bg-accent px-6 py-3 text-body font-semibold text-accent-text transition-transform active:scale-[.98]"
|
||||
>
|
||||
{t('native.lock.unlock')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { tripsApi } from '../../api/client';
|
||||
import { useSharedImportStore } from '../../native/sharedFiles';
|
||||
import NativeImportSheet from './NativeImportSheet';
|
||||
|
||||
const navigate = vi.hoisted(() => vi.fn());
|
||||
vi.mock('react-router', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('react-router')>()),
|
||||
useNavigate: () => navigate,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
navigate.mockReset();
|
||||
useSharedImportStore.setState({ file: null, tripId: null });
|
||||
vi.spyOn(tripsApi, 'list').mockResolvedValue({
|
||||
trips: [
|
||||
{ id: 1, title: 'Past trip', start_date: '2020-01-01', end_date: '2020-01-05' },
|
||||
{ id: 2, title: 'Japan', start_date: '2099-03-01', end_date: '2099-03-10' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe('NativeImportSheet', () => {
|
||||
it('asks which trip a shared document goes to, upcoming trips first, and opens it there', async () => {
|
||||
render(<NativeImportSheet />);
|
||||
act(() => useSharedImportStore.getState().setFile(new File(['x'], 'booking.pdf', { type: 'application/pdf' })));
|
||||
|
||||
const rows = await screen.findAllByRole('button', { name: /Japan|Past trip/ });
|
||||
expect(rows.map((r) => r.textContent)).toEqual(['Japan', 'Past trip']);
|
||||
expect(screen.getByText('booking.pdf')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(rows[0]);
|
||||
expect(useSharedImportStore.getState().tripId).toBe(2);
|
||||
expect(navigate).toHaveBeenCalledWith('/trips/2');
|
||||
});
|
||||
|
||||
it('stays closed with nothing shared', () => {
|
||||
render(<NativeImportSheet />);
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(tripsApi.list).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* NativeImportSheet: a booking document was shared to TREK (native/sharedFiles.ts),
|
||||
* so this asks which trip it belongs to and opens that trip's booking import with
|
||||
* the file already chosen. Trips still ahead come first, the ones that ended after.
|
||||
*/
|
||||
import { ChevronRight, FileText } from 'lucide-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { tripsApi } from '../../api/client';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import MSheet from '../../mobile/components/MSheet';
|
||||
import { useSharedImportStore } from '../../native/sharedFiles';
|
||||
import { TileHeader } from '../../mobile/screens/trip/sheets/MTripSheetUi'
|
||||
|
||||
interface TripRow {
|
||||
id: number;
|
||||
title: string;
|
||||
start_date?: string | null;
|
||||
end_date?: string | null;
|
||||
}
|
||||
|
||||
export default function NativeImportSheet(): React.ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const file = useSharedImportStore((s) => s.file);
|
||||
const tripId = useSharedImportStore((s) => s.tripId);
|
||||
const chooseTrip = useSharedImportStore((s) => s.chooseTrip);
|
||||
const setFile = useSharedImportStore((s) => s.setFile);
|
||||
const [trips, setTrips] = useState<TripRow[]>([]);
|
||||
|
||||
const open = !!file && tripId == null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
tripsApi.list().then(
|
||||
(data: { trips?: TripRow[] }) => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const list = [...(data.trips ?? [])];
|
||||
const ahead = (trip: TripRow) => (trip.end_date ?? '9999') >= today;
|
||||
list.sort(
|
||||
(a, b) => Number(ahead(b)) - Number(ahead(a)) || (a.start_date ?? '').localeCompare(b.start_date ?? '')
|
||||
);
|
||||
setTrips(list);
|
||||
},
|
||||
() => setTrips([])
|
||||
);
|
||||
}, [open]);
|
||||
|
||||
const pick = (id: number) => {
|
||||
chooseTrip(id);
|
||||
navigate(`/trips/${id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<MSheet open={open} onClose={() => setFile(null)} variant="bottom" ariaLabel={t('native.share.title')}>
|
||||
<div className="px-[18px] pb-4 pt-4">
|
||||
<div className="mb-3">
|
||||
<TileHeader
|
||||
icon={<FileText size={19} strokeWidth={1.8} />}
|
||||
title={t('native.share.title')}
|
||||
sub={file?.name}
|
||||
onClose={() => setFile(null)}
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex max-h-[50vh] flex-col gap-2 overflow-y-auto">
|
||||
{trips.map((trip) => (
|
||||
<button
|
||||
key={trip.id}
|
||||
type="button"
|
||||
onClick={() => pick(trip.id)}
|
||||
className="flex items-center gap-3 rounded-[16px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-4 py-3 text-start transition-transform active:scale-[.98]"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-[length:calc(14px*var(--fs-scale-body,1))] font-bold text-m-ink">
|
||||
{trip.title}
|
||||
</span>
|
||||
<ChevronRight size={16} className="flex-none text-m-faint" aria-hidden="true" />
|
||||
</button>
|
||||
))}
|
||||
{trips.length === 0 && (
|
||||
<p className="px-1 text-[length:calc(12px*var(--fs-scale-body,1))] text-m-muted">
|
||||
{t('native.share.noTrips')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</MSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { _resetNativeCapabilities, checkAppUpdate, loadNativeCapabilities } from '../../native/capabilities';
|
||||
import { isNativeApp } from '../../native/platform';
|
||||
import { TrekShell } from '../../native/trekShell';
|
||||
import NativeUpdateNotice from './NativeUpdateNotice';
|
||||
|
||||
vi.mock('../../native/platform', () => ({ isNativeApp: vi.fn(), isNativeIos: vi.fn(() => false) }));
|
||||
vi.mock('../../native/trekShell', () => ({
|
||||
TrekShell: { getCapabilities: vi.fn() },
|
||||
isUnimplemented: () => false,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
_resetNativeCapabilities();
|
||||
vi.mocked(isNativeApp).mockReturnValue(true);
|
||||
vi.mocked(TrekShell.getCapabilities).mockResolvedValue({
|
||||
shellApi: 2,
|
||||
platform: 'android',
|
||||
appVersion: '5.2.0',
|
||||
build: '3',
|
||||
features: [],
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe('NativeUpdateNotice', () => {
|
||||
it('stays away while the binary is new enough', async () => {
|
||||
render(<NativeUpdateNotice />);
|
||||
await act(async () => {
|
||||
await checkAppUpdate(async () => ({ minNativeShellApi: 2 }));
|
||||
});
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('leads to the store when the server needs a newer binary, and can be dismissed', async () => {
|
||||
render(<NativeUpdateNotice />);
|
||||
await act(async () => {
|
||||
await loadNativeCapabilities();
|
||||
await checkAppUpdate(async () => ({ minNativeShellApi: 3 }));
|
||||
});
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', 'https://play.google.com/store/apps/details?id=com.liketrek.trek');
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* NativeUpdateNotice: this server's UI needs a newer app binary than the one
|
||||
* installed (native/capabilities.ts, minNativeShellApi). The UI updates with
|
||||
* the server, the binary only through the store, so the two can drift; the
|
||||
* notice says so and leads to the store instead of leaving native calls to
|
||||
* fail one by one. Rendered once in App, inside the app only.
|
||||
*/
|
||||
import { Download, X } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useNativeCapabilities } from '../../native/capabilities';
|
||||
|
||||
const STORE_URL = {
|
||||
ios: 'https://apps.apple.com/app/id6831237775',
|
||||
android: 'https://play.google.com/store/apps/details?id=com.liketrek.trek',
|
||||
} as const;
|
||||
|
||||
export default function NativeUpdateNotice(): React.ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
const { caps, updateRequired } = useNativeCapabilities();
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
if (!updateRequired || dismissed || caps.platform === 'web') return null;
|
||||
const store = caps.platform === 'android' ? STORE_URL.android : STORE_URL.ios;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
style={{ bottom: 'calc(var(--bottom-nav-h) + 56px)' }}
|
||||
className="fixed inset-x-4 z-[var(--z-notice)] flex items-center gap-3 rounded-2xl border border-edge bg-surface-elevated px-4 py-3 text-content shadow-lg"
|
||||
>
|
||||
<Download size={18} className="shrink-0 text-content-muted" aria-hidden="true" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-body font-semibold">{t('native.update.title')}</div>
|
||||
<div className="text-caption text-content-secondary">{t('native.update.body')}</div>
|
||||
</div>
|
||||
<a
|
||||
href={store}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="shrink-0 rounded-full bg-accent px-3 py-1.5 text-caption font-semibold text-accent-text"
|
||||
>
|
||||
{t('native.update.open')}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDismissed(true)}
|
||||
aria-label={t('common.close')}
|
||||
className="shrink-0 rounded-full p-1 text-content-muted"
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* NativeWidgetsSync: keeps the iOS home-screen widget and the travel-day Live
|
||||
* Activity in step with the trips stored on this device (native/widgets.ts).
|
||||
* Runs at start, whenever the app comes back to the foreground and every
|
||||
* quarter hour while it is open, so the next stop moves on through the day.
|
||||
* Renders nothing; outside an iOS binary with the widgets it does nothing.
|
||||
*/
|
||||
import { App } from '@capacitor/app';
|
||||
import { useEffect } from 'react';
|
||||
import { offlineDb } from '../../db/offlineDb';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useNativeCapabilities } from '../../native/capabilities';
|
||||
import { buildSnapshot, pickTrip, publishWidgets, todayOf, tripDayState, widgetsActive } from '../../native/widgets';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { formatTime } from '../../utils/formatters';
|
||||
import { localDateStr } from '../../vacay/monthGrid';
|
||||
|
||||
const REFRESH_MS = 15 * 60 * 1000;
|
||||
|
||||
export default function NativeWidgetsSync(): null {
|
||||
const { t, language } = useTranslation();
|
||||
const { caps } = useNativeCapabilities();
|
||||
const timeFormat = useSettingsStore((s) => s.settings.time_format);
|
||||
|
||||
useEffect(() => {
|
||||
if (!widgetsActive()) return;
|
||||
let cancelled = false;
|
||||
|
||||
const sync = async () => {
|
||||
const now = new Date();
|
||||
const today = localDateStr(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const trips = await offlineDb.trips.toArray();
|
||||
const trip = pickTrip(trips, today);
|
||||
const days = trip ? await offlineDb.days.where('trip_id').equals(trip.id).toArray() : [];
|
||||
const day = trip
|
||||
? todayOf(
|
||||
trip,
|
||||
days,
|
||||
today,
|
||||
(n, m) => t('native.widget.dayOf', { n, m }),
|
||||
(time) => formatTime(time, language, timeFormat)
|
||||
)
|
||||
: null;
|
||||
if (cancelled) return;
|
||||
const snapshot = buildSnapshot(trip, day, {
|
||||
nextTrip: t('dashboard.nextTrip'),
|
||||
today: t('native.widget.today'),
|
||||
noTrip: t('native.widget.noTrip'),
|
||||
inDays: t('native.widget.inDays'),
|
||||
tomorrow: t('native.widget.tomorrow'),
|
||||
nothingPlanned: t('native.widget.nothingPlanned'),
|
||||
});
|
||||
const live = day ? tripDayState(day, now.getHours() * 60 + now.getMinutes(), t('native.widget.dayDone')) : null;
|
||||
await publishWidgets(snapshot, live);
|
||||
};
|
||||
|
||||
void sync();
|
||||
const timer = window.setInterval(() => void sync(), REFRESH_MS);
|
||||
const listener = App.addListener('appStateChange', ({ isActive }) => {
|
||||
if (isActive) void sync();
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(timer);
|
||||
void listener.then((handle) => handle.remove());
|
||||
};
|
||||
}, [caps, t, language, timeFormat]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -69,14 +69,19 @@ export default function LocationButton({ mode, error, errorCode, onClick, bottom
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: bottomOffset,
|
||||
right: 12,
|
||||
// The map's base-layer switcher sits 20px off the left edge; this mirrors it.
|
||||
right: 20,
|
||||
zIndex: 1000,
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
background: isActive ? '#3b82f6' : 'var(--bg-card, white)',
|
||||
// Off, it wears the same frosted glass as the map's other round controls;
|
||||
// on, the location blue says it is tracking.
|
||||
background: isActive ? '#3b82f6' : 'var(--sidebar-bg)',
|
||||
backdropFilter: isActive ? undefined : 'blur(20px) saturate(180%)',
|
||||
WebkitBackdropFilter: isActive ? undefined : 'blur(20px) saturate(180%)',
|
||||
color: isActive ? 'white' : (error ? '#ef4444' : 'var(--text-muted, #6b7280)'),
|
||||
boxShadow: MAP_CONTROL_SHADOW,
|
||||
display: 'flex',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-MAPCOMPASS-001 to FE-COMP-MAPCOMPASS-006
|
||||
// FE-COMP-MAPCOMPASS-001 to FE-COMP-MAPCOMPASS-007
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, act } from '../../../tests/helpers/render'
|
||||
@@ -86,3 +86,17 @@ describe('MapCompassPill', () => {
|
||||
expect(button.style.background).toBe('transparent')
|
||||
})
|
||||
})
|
||||
|
||||
describe('MapCompassPill on the phone map', () => {
|
||||
it('FE-COMP-MAPCOMPASS-007: appears only while the map is turned off north', () => {
|
||||
const { map, rotateTo } = fakeCompassMap(0)
|
||||
render(<MapCompassPill map={map} onlyWhenRotated />)
|
||||
expect(screen.queryByRole('button', { name: 'Reset north' })).not.toBeInTheDocument()
|
||||
|
||||
rotateTo(30)
|
||||
expect(screen.getByRole('button', { name: 'Reset north' })).toBeInTheDocument()
|
||||
|
||||
rotateTo(0)
|
||||
expect(screen.queryByRole('button', { name: 'Reset north' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,7 +15,12 @@ export interface CompassMap {
|
||||
* (GL only) and built as the SAME frosted shell (padding 4 around a 34px button)
|
||||
* so its height and transparency match the POI pill exactly.
|
||||
*/
|
||||
export function MapCompassPill({ map }: { map: CompassMap }) {
|
||||
export function MapCompassPill({ map, onlyWhenRotated = false }: {
|
||||
map: CompassMap
|
||||
/** Shown only while the map is turned off north, the way a native map's compass
|
||||
* appears when it has something to undo (the phone map, where room is short). */
|
||||
onlyWhenRotated?: boolean
|
||||
}) {
|
||||
const [bearing, setBearing] = useState(() => map.getBearing())
|
||||
|
||||
useEffect(() => {
|
||||
@@ -25,6 +30,8 @@ export function MapCompassPill({ map }: { map: CompassMap }) {
|
||||
return () => { map.off('rotate', update) }
|
||||
}, [map])
|
||||
|
||||
if (onlyWhenRotated && Math.abs(bearing) < 0.5) return null
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto',
|
||||
|
||||
@@ -68,6 +68,7 @@ import { tileHealthHandlers, tourRasterBaseOf } from './tourRasterBases'
|
||||
import { crsForBasemap } from './gcj02Crs'
|
||||
import { isGcj02Basemap, resolveBasemap } from '../../utils/tileUrl'
|
||||
import VectorBasemap from './VectorBasemap'
|
||||
import { nativeRasterTemplate } from '../../native/tileCache'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { TourMapLayersLeaflet } from './RasterOverlayLeaflet'
|
||||
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type TourBaseLayer } from './MapLayerSwitcher'
|
||||
@@ -938,7 +939,7 @@ export const MapView = memo(function MapView({
|
||||
) : (
|
||||
<TileLayer
|
||||
key="raster"
|
||||
url={basemap.url}
|
||||
url={nativeRasterTemplate(basemap.url)}
|
||||
attribution={attributionForTile(basemap.url)}
|
||||
maxZoom={19}
|
||||
keepBuffer={8}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* The trip map offline (FE-COMP-MAPAUTO-OFFLINE-*).
|
||||
*
|
||||
* Whatever basemap the user picked, offline the map never goes grey: a basemap
|
||||
* with nothing stored switches to the stored OpenFreeMap one, with the same
|
||||
* markers and routes, and says so.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { resetWebGLProbe } from '../../utils/webgl';
|
||||
import { MapViewAuto } from './MapViewAuto';
|
||||
|
||||
const env = vi.hoisted(() => ({ offline: false, dark: false, tileStore: false }));
|
||||
const seen = vi.hoisted(() => ({
|
||||
leaflet: null as null | { tileUrl?: string },
|
||||
gl: null as null | { engine: string; glProvider?: string; styleOverride?: string },
|
||||
}));
|
||||
|
||||
vi.mock('./MapView', () => ({
|
||||
MapView: (props: { tileUrl?: string }) => {
|
||||
seen.leaflet = props;
|
||||
return <div data-testid="leaflet-map" />;
|
||||
},
|
||||
}));
|
||||
vi.mock('./glLazy', () => ({
|
||||
MapViewGLMaplibre: (props: { glProvider?: string; styleOverride?: string }) => {
|
||||
seen.gl = { engine: 'maplibre', ...props };
|
||||
return <div data-testid="gl-map" />;
|
||||
},
|
||||
MapViewGLMapbox: (props: { glProvider?: string; styleOverride?: string }) => {
|
||||
seen.gl = { engine: 'mapbox', ...props };
|
||||
return <div data-testid="gl-map" />;
|
||||
},
|
||||
}));
|
||||
vi.mock('./useRoadtripHazards', () => ({ useRoadtripHazards: () => ({ feed: null }) }));
|
||||
vi.mock('../../hooks/useNetworkMode', () => ({
|
||||
useNetworkMode: () => ({ offline: env.offline, forced: false, setForced: () => {} }),
|
||||
}));
|
||||
vi.mock('../../hooks/useIsDark', () => ({ useIsDark: () => env.dark }));
|
||||
vi.mock('../../native/tileCache', () => ({ tileCacheActive: () => env.tileStore }));
|
||||
vi.mock('../../native/platform', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../native/platform')>()),
|
||||
isNativeIos: () => false,
|
||||
}));
|
||||
|
||||
const realGetContext = HTMLCanvasElement.prototype.getContext;
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
resetWebGLProbe();
|
||||
env.offline = false;
|
||||
env.dark = false;
|
||||
env.tileStore = false;
|
||||
seen.leaflet = null;
|
||||
seen.gl = null;
|
||||
const ctx = { getExtension: () => ({ loseContext: () => {} }) };
|
||||
HTMLCanvasElement.prototype.getContext = vi.fn((type: string) =>
|
||||
type === 'webgl2' ? ctx : null
|
||||
) as unknown as typeof realGetContext;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
HTMLCanvasElement.prototype.getContext = realGetContext;
|
||||
resetWebGLProbe();
|
||||
});
|
||||
|
||||
const pill = () => screen.queryByRole('status');
|
||||
|
||||
describe('MapViewAuto offline', () => {
|
||||
it('FE-COMP-MAPAUTO-OFFLINE-001: a Mapbox map online stays Mapbox, with no pill', async () => {
|
||||
seedStore(useSettingsStore, { settings: { map_provider: 'mapbox-gl', mapbox_access_token: 'pk.test' } });
|
||||
|
||||
render(<MapViewAuto places={[]} />);
|
||||
|
||||
await screen.findByTestId('gl-map');
|
||||
expect(seen.gl).toMatchObject({ engine: 'mapbox', glProvider: 'mapbox-gl', styleOverride: undefined });
|
||||
expect(pill()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAPAUTO-OFFLINE-002: offline, a Mapbox map draws the stored basemap with MapLibre and says so', async () => {
|
||||
seedStore(useSettingsStore, { settings: { map_provider: 'mapbox-gl', mapbox_access_token: 'pk.test' } });
|
||||
env.offline = true;
|
||||
env.dark = true;
|
||||
|
||||
render(<MapViewAuto places={[]} />);
|
||||
|
||||
await screen.findByTestId('gl-map');
|
||||
expect(seen.gl).toMatchObject({ engine: 'maplibre', glProvider: 'maplibre-gl', styleOverride: OFM_DARK });
|
||||
expect(pill()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAPAUTO-OFFLINE-003: a MapLibre map already on a stored style stays as it is', async () => {
|
||||
seedStore(useSettingsStore, { settings: { map_provider: 'maplibre-gl', maplibre_style: OFM_POSITRON } });
|
||||
env.offline = true;
|
||||
|
||||
render(<MapViewAuto places={[]} />);
|
||||
|
||||
await screen.findByTestId('gl-map');
|
||||
expect(seen.gl?.styleOverride).toBeUndefined();
|
||||
expect(pill()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAPAUTO-OFFLINE-004: offline, Leaflet on OpenStreetMap tiles switches to the stored basemap', () => {
|
||||
seedStore(useSettingsStore, { settings: { map_provider: 'leaflet' } });
|
||||
env.offline = true;
|
||||
|
||||
render(<MapViewAuto places={[]} tileUrl="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />);
|
||||
|
||||
expect(seen.leaflet?.tileUrl).toBe(OFM_POSITRON);
|
||||
expect(pill()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAPAUTO-OFFLINE-005: offline, a raster template the service worker stored stays', () => {
|
||||
seedStore(useSettingsStore, { settings: { map_provider: 'leaflet' } });
|
||||
env.offline = true;
|
||||
|
||||
render(<MapViewAuto places={[]} tileUrl="https://tiles.example.com/{z}/{x}/{y}.png" />);
|
||||
|
||||
expect(seen.leaflet?.tileUrl).toBe('https://tiles.example.com/{z}/{x}/{y}.png');
|
||||
expect(pill()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAPAUTO-OFFLINE-006: in the iOS app with its tile store, Mapbox stays offline and the pill switches to the stored map', async () => {
|
||||
seedStore(useSettingsStore, { settings: { map_provider: 'mapbox-gl', mapbox_access_token: 'pk.test' } });
|
||||
env.offline = true;
|
||||
env.tileStore = true;
|
||||
|
||||
render(<MapViewAuto places={[]} />);
|
||||
|
||||
await screen.findByTestId('gl-map');
|
||||
expect(seen.gl).toMatchObject({ engine: 'mapbox', styleOverride: undefined });
|
||||
const toggle = screen.getByRole('button', { pressed: false });
|
||||
fireEvent.click(toggle);
|
||||
await screen.findByTestId('gl-map');
|
||||
expect(seen.gl).toMatchObject({ engine: 'maplibre', styleOverride: OFM_POSITRON });
|
||||
expect(screen.getByRole('button', { pressed: true })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAPAUTO-OFFLINE-007: with the tile store, a raster template stays offline in the app too', () => {
|
||||
seedStore(useSettingsStore, { settings: { map_provider: 'leaflet' } });
|
||||
env.offline = true;
|
||||
env.tileStore = true;
|
||||
|
||||
render(<MapViewAuto places={[]} tileUrl="https://tiles.example.com/{z}/{x}/{y}.png" />);
|
||||
|
||||
expect(seen.leaflet?.tileUrl).toBe('https://tiles.example.com/{z}/{x}/{y}.png');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Suspense } from 'react'
|
||||
import { AlertTriangle, Clock, Info } from 'lucide-react'
|
||||
import { Suspense, useState } from 'react'
|
||||
import { AlertTriangle, Clock, CloudOff, Info } from 'lucide-react'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { MapView } from './MapView'
|
||||
@@ -9,15 +9,22 @@ import { useGlRenderer } from './glSupport'
|
||||
import { useRoadtripHazards } from './useRoadtripHazards'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import type { MapViewAutoProps, MapViewProps } from './mapViewContract'
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode'
|
||||
import { useIsDark } from '../../hooks/useIsDark'
|
||||
import { OFM_POSITRON } from '../../constants/mapDefaults'
|
||||
import { glWorksOffline, leafletWorksOffline, offlineStyleFor } from './offlineBasemap'
|
||||
import { tileCacheActive } from '../../native/tileCache'
|
||||
|
||||
// Auto-selects the map renderer based on user settings. Keeps the existing
|
||||
// Leaflet MapView untouched so the Mapbox GL variant can mature iteratively
|
||||
// behind a toggle. Atlas is not affected — it imports Leaflet directly.
|
||||
//
|
||||
// Offline maps: only the Leaflet renderer supports full pre-download (raster
|
||||
// tiles via sync/tilePrefetcher.ts). GL maps are best-effort offline — their
|
||||
// vector tiles are cached opportunistically by the Service Worker as you view
|
||||
// them online (see the GL tile rules in vite.config.js), not prefetched.
|
||||
// Offline maps: "prepare for offline" stores the OpenFreeMap basemap for every
|
||||
// trip, whatever the provider (sync/tilePrefetcher.ts). Offline, a map whose own
|
||||
// basemap cannot be drawn from storage (Mapbox, another OpenFreeMap style, a
|
||||
// raster template the device cannot serve) switches to that stored basemap,
|
||||
// with the same markers, routes and tracks, and says so with a small pill. It
|
||||
// switches back once the connection returns. See offlineBasemap.ts.
|
||||
export function MapViewAuto(props: MapViewAutoProps) {
|
||||
const { t } = useTranslation()
|
||||
const hazards = useRoadtripHazards(props.tripId, !!props.clusterLoosely)
|
||||
@@ -32,7 +39,31 @@ export function MapViewAuto(props: MapViewAutoProps) {
|
||||
// Fall back to Leaflet when Mapbox is selected but no token is set, or when the
|
||||
// browser has no WebGL2 (both GL engines need it), so the trip planner never
|
||||
// shows an empty map or one that fails to start.
|
||||
const glProvider = useGlRenderer(provider, token)
|
||||
const configuredGl = useGlRenderer(provider, token)
|
||||
const maplibreStyle = useSettingsStore(s => s.settings.maplibre_style)
|
||||
const { offline } = useNetworkMode()
|
||||
const dark = useIsDark()
|
||||
const offlineStyle = offlineStyleFor(dark)
|
||||
// In the iOS app with its tile store, a Mapbox map keeps what was viewed for 30
|
||||
// days, so offline it stays Mapbox, and the pill offers the stored trip region
|
||||
// instead for the places never looked at. Everywhere else a basemap with
|
||||
// nothing stored is swapped for the stored one straight away.
|
||||
const nativeStore = tileCacheActive()
|
||||
const [preferStored, setPreferStored] = useState(false)
|
||||
const mapboxKept = configuredGl === 'mapbox-gl' && nativeStore
|
||||
const glFallback = !!configuredGl && offline && !glWorksOffline(configuredGl, maplibreStyle) && (!mapboxKept || preferStored)
|
||||
const glProvider = glFallback ? 'maplibre-gl' : configuredGl
|
||||
const leafletFallback = !configuredGl && offline && !leafletWorksOffline(mapProps.tileUrl ?? OFM_POSITRON, nativeIosWithoutStore(nativeStore))
|
||||
if (leafletFallback) mapProps.tileUrl = offlineStyle
|
||||
const pill = offline && mapboxKept
|
||||
? (
|
||||
<OfflineBasemapPill
|
||||
label={preferStored ? t('map.offlineBasemap') : t('map.offlineShowStored')}
|
||||
pressed={preferStored}
|
||||
onToggle={() => setPreferStored(v => !v)}
|
||||
/>
|
||||
)
|
||||
: (glFallback || leafletFallback) && <OfflineBasemapPill label={t('map.offlineBasemap')} />
|
||||
// One chunk per engine: picking the binding here is what keeps mapbox-gl and
|
||||
// maplibre-gl out of each other's downloads.
|
||||
const MapViewGL = glProvider === 'maplibre-gl' ? MapViewGLMaplibre : MapViewGLMapbox
|
||||
@@ -45,12 +76,49 @@ export function MapViewAuto(props: MapViewAutoProps) {
|
||||
// to Leaflet keeps a usable map instead of an error card.
|
||||
// resetKeys: with two engine chunks, a failure under one provider must not
|
||||
// keep showing Leaflet after the user switches to the other.
|
||||
<ErrorBoundary boundaryId="map:gl" resetKeys={[glProvider]} fallback={<MapView {...mapProps} />}>
|
||||
<Suspense fallback={<MapView {...mapProps} />}>
|
||||
<MapViewGL {...mapProps} glProvider={glProvider} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
<>
|
||||
<ErrorBoundary boundaryId="map:gl" resetKeys={[glProvider]} fallback={<MapView {...mapProps} />}>
|
||||
<Suspense fallback={<MapView {...mapProps} />}>
|
||||
<MapViewGL {...mapProps} glProvider={glProvider} styleOverride={glFallback ? offlineStyle : undefined} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
{pill}
|
||||
</>
|
||||
)
|
||||
}
|
||||
return <MapView {...mapProps} />
|
||||
return (
|
||||
<>
|
||||
<MapView {...mapProps} />
|
||||
{pill}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** A raster template cannot be served offline in the iOS app unless it has its tile store. */
|
||||
function nativeIosWithoutStore(nativeStore: boolean): boolean | undefined {
|
||||
return nativeStore ? false : undefined
|
||||
}
|
||||
|
||||
const PILL = 'absolute left-1/2 z-[5] flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-edge bg-surface-card px-3 py-1 text-caption font-semibold text-content-secondary shadow-sm backdrop-blur-md bottom-[calc(var(--bottom-nav-h,0px)+12px)]'
|
||||
|
||||
/**
|
||||
* Says the map on screen is the stored offline basemap, not the user's own. With
|
||||
* onToggle it is a switch between the user's map (as far as it was viewed and
|
||||
* stored) and the stored trip region.
|
||||
*/
|
||||
function OfflineBasemapPill({ label, pressed, onToggle }: { label: string; pressed?: boolean; onToggle?: () => void }) {
|
||||
if (onToggle) {
|
||||
return (
|
||||
<button type="button" onClick={onToggle} aria-pressed={pressed} className={`${PILL} pointer-events-auto`}>
|
||||
<CloudOff size={13} strokeWidth={2.2} aria-hidden="true" />
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div role="status" className={`${PILL} pointer-events-none`}>
|
||||
<CloudOff size={13} strokeWidth={2.2} aria-hidden="true" />
|
||||
{label}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { visibleRouteReservations } from '../../utils/reservationRoutes'
|
||||
import { safeHexColor } from '../../utils/safeColor'
|
||||
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders'
|
||||
import { buildGlMap, initialMapStyle } from './glSupport'
|
||||
import { mapboxTileCacheRequest, tileCacheActive } from '../../native/tileCache'
|
||||
import LocationButton from './LocationButton'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import { useGeolocation } from '../../hooks/useGeolocation'
|
||||
@@ -100,6 +101,8 @@ const NO_DAYS: Day[] = []
|
||||
/** The GL renderer: the shared map contract plus the engine it is bound to. */
|
||||
interface Props extends MapViewProps {
|
||||
glProvider?: GlMapProvider
|
||||
/** Draws this style instead of the user's: the offline basemap (offlineBasemap.ts). */
|
||||
styleOverride?: string
|
||||
/**
|
||||
* The GL engine, injected instead of imported. Both SDKs used to be pulled in
|
||||
* statically here, so a single 2.8 MB chunk carried mapbox-gl and maplibre-gl
|
||||
@@ -482,6 +485,7 @@ export function MapViewGL({
|
||||
onRemoveVia,
|
||||
onViewportChange,
|
||||
glProvider = 'mapbox-gl',
|
||||
styleOverride,
|
||||
gl,
|
||||
onMapReady,
|
||||
...assistant
|
||||
@@ -506,7 +510,7 @@ export function MapViewGL({
|
||||
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) === true
|
||||
const mapLang = useSettingsStore(s => s.settings.language)
|
||||
const isMapLibre = glProvider === 'maplibre-gl'
|
||||
const glStyle = styleForActiveProvider(glProvider, rawMapboxStyle, rawMaplibreStyle)
|
||||
const glStyle = styleOverride ?? styleForActiveProvider(glProvider, rawMapboxStyle, rawMaplibreStyle)
|
||||
const enableMapbox3d = !isMapLibre && mapbox3d
|
||||
const [mapReady, setMapReady] = useState(false)
|
||||
const [readyMap, setReadyMap] = useState<FerrataGlMap | null>(null) // the ready map, read without touching mapRef
|
||||
@@ -1015,6 +1019,9 @@ export function MapViewGL({
|
||||
antialias: mapboxQuality,
|
||||
}
|
||||
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
|
||||
// In the iOS app, Mapbox's own resources go through the app's tile store, so
|
||||
// what was viewed stays on the device for up to 30 days (native/tileCache.ts).
|
||||
if (!isMapLibre && tileCacheActive()) mapOptions.transformRequest = mapboxTileCacheRequest
|
||||
// MapLibre's around-center rotate ping-pongs near mid-screen (#1545); false is mapbox-gl's dx rotate.
|
||||
if (isMapLibre) mapOptions.aroundCenter = false
|
||||
|
||||
|
||||
@@ -146,6 +146,17 @@ describe('PoiCategoryPill', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-COMP-POIPILL-013b: in the phone chrome the bar wears the day rail glass at its 36px height', () => {
|
||||
pill({ categories: WITH_PLUGINS, fullWidth: true, phoneChrome: true })
|
||||
for (const button of screen.getAllByRole('button')) {
|
||||
expect(button.style.minWidth).toBe('34px')
|
||||
expect(button.style.height).toBe('28px')
|
||||
}
|
||||
const bar = screen.getAllByRole('button')[0].closest('[style*="overflow"]') as HTMLElement
|
||||
expect(bar.style.padding).toBe('3px')
|
||||
expect(bar.style.background).toBe('var(--m-glass)')
|
||||
})
|
||||
|
||||
it('FE-COMP-POIPILL-014: a desktop segment keeps its 34px instead of shrinking in a narrow corridor', () => {
|
||||
pill({ categories: WITH_PLUGINS })
|
||||
for (const button of screen.getAllByRole('button')) {
|
||||
|
||||
@@ -18,10 +18,23 @@ interface Props {
|
||||
* The phone map gives it the full width between the screen margins; on
|
||||
* desktop it floats, so it stays content-width there. */
|
||||
fullWidth?: boolean
|
||||
/** The phone shell's chrome: the glass, hairline and 36px height of the day rail
|
||||
* and the tab switch above it, so the three bars read as one set. */
|
||||
phoneChrome?: boolean
|
||||
}
|
||||
|
||||
// The touch target every segment keeps, however many categories there are.
|
||||
const SEGMENT = 34
|
||||
// On the phone the segment is as tall as a day chip; it keeps its width.
|
||||
const PHONE_SEGMENT_HEIGHT = 28
|
||||
|
||||
/** The phone shell's glass, as the day rail draws it (bg/border --m-glass/--m-gbr). */
|
||||
const PHONE_GLASS: React.CSSProperties = {
|
||||
background: 'var(--m-glass)',
|
||||
border: '1px solid var(--m-gbr)',
|
||||
backdropFilter: 'blur(24px) saturate(170%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(170%)',
|
||||
}
|
||||
|
||||
// How far one wheel step moves the bar. Firefox counts a mouse wheel in lines and
|
||||
// some drivers in pages; a line moves about one segment, a page one bar width.
|
||||
@@ -50,10 +63,11 @@ interface SegmentProps {
|
||||
loading: boolean
|
||||
failed: boolean
|
||||
fullWidth?: boolean
|
||||
phoneChrome?: boolean
|
||||
onToggle: (key: string) => void
|
||||
}
|
||||
|
||||
function Segment({ cat, on, loading, failed, fullWidth, onToggle }: Readonly<SegmentProps>) {
|
||||
function Segment({ cat, on, loading, failed, fullWidth, phoneChrome, onToggle }: Readonly<SegmentProps>) {
|
||||
return (
|
||||
<Tooltip label={cat.label} placement="bottom">
|
||||
<button
|
||||
@@ -71,7 +85,7 @@ function Segment({ cat, on, loading, failed, fullWidth, onToggle }: Readonly<Seg
|
||||
flexShrink: fullWidth ? 1 : 0,
|
||||
flexBasis: fullWidth ? 0 : undefined,
|
||||
minWidth: SEGMENT,
|
||||
width: fullWidth ? 'auto' : SEGMENT, height: SEGMENT, borderRadius: 999, border: 'none', cursor: 'pointer',
|
||||
width: fullWidth ? 'auto' : SEGMENT, height: phoneChrome ? PHONE_SEGMENT_HEIGHT : SEGMENT, borderRadius: 999, border: 'none', cursor: 'pointer',
|
||||
background: on ? cat.color : 'transparent',
|
||||
color: on ? '#fff' : undefined,
|
||||
transition: 'background 0.14s, color 0.14s',
|
||||
@@ -108,11 +122,11 @@ function Segment({ cat, on, loading, failed, fullWidth, onToggle }: Readonly<Seg
|
||||
// and whatever plugins add follows a thin divider (#1781); once that is more than the
|
||||
// width holds, the bar scrolls sideways (by swipe, trackpad or mouse wheel) rather than
|
||||
// shrinking a segment below the touch target.
|
||||
export default function PoiCategoryPill({ categories, active, onToggle, loadingKeys, errorKeys, moved, onSearchArea, fullWidth }: Readonly<Props>) {
|
||||
export default function PoiCategoryPill({ categories, active, onToggle, loadingKeys, errorKeys, moved, onSearchArea, fullWidth, phoneChrome }: Readonly<Props>) {
|
||||
const { t } = useTranslation()
|
||||
const anyError = !!errorKeys && Array.from(active).some(k => errorKeys.has(k))
|
||||
|
||||
const frosted: React.CSSProperties = {
|
||||
const frosted: React.CSSProperties = phoneChrome ? PHONE_GLASS : {
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(20px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
||||
@@ -132,6 +146,7 @@ export default function PoiCategoryPill({ categories, active, onToggle, loadingK
|
||||
loading={loading}
|
||||
failed={!!errorKeys?.has(cat.key)}
|
||||
fullWidth={fullWidth}
|
||||
phoneChrome={phoneChrome}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
)
|
||||
@@ -142,7 +157,7 @@ export default function PoiCategoryPill({ categories, active, onToggle, loadingK
|
||||
<div onWheel={wheelSideways} style={{
|
||||
display: fullWidth ? 'flex' : 'inline-flex',
|
||||
alignSelf: fullWidth ? 'stretch' : undefined,
|
||||
alignItems: 'center', gap: 2, padding: 4, borderRadius: 999, pointerEvents: 'auto', ...frosted,
|
||||
alignItems: 'center', gap: 2, padding: phoneChrome ? 3 : 4, borderRadius: 999, pointerEvents: 'auto', ...frosted,
|
||||
maxWidth: '100%', overflowX: 'auto', scrollbarWidth: 'none',
|
||||
}}>
|
||||
{categories.core.map(segment)}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults';
|
||||
import { glWorksOffline, isOfflineForbiddenTemplate, leafletWorksOffline, offlineStyleFor } from './offlineBasemap';
|
||||
|
||||
describe('offlineBasemap', () => {
|
||||
it('picks the stored style for the colour scheme', () => {
|
||||
expect(offlineStyleFor(false)).toBe(OFM_POSITRON);
|
||||
expect(offlineStyleFor(true)).toBe(OFM_DARK);
|
||||
});
|
||||
|
||||
it('knows OpenStreetMap standard tiles may not be stored, sharded or not', () => {
|
||||
expect(isOfflineForbiddenTemplate('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png')).toBe(true);
|
||||
expect(isOfflineForbiddenTemplate('https://tile.openstreetmap.org/{z}/{x}/{y}.png')).toBe(true);
|
||||
expect(isOfflineForbiddenTemplate('https://tile.openstreetmap.de/{z}/{x}/{y}.png')).toBe(false);
|
||||
expect(isOfflineForbiddenTemplate('not a url')).toBe(false);
|
||||
});
|
||||
|
||||
it('lets only MapLibre on a stored style draw offline as configured', () => {
|
||||
expect(glWorksOffline('maplibre-gl', OFM_POSITRON)).toBe(true);
|
||||
expect(glWorksOffline('maplibre-gl', OFM_DARK)).toBe(true);
|
||||
// The default MapLibre style is Liberty, whose sprite and glyphs are not stored.
|
||||
expect(glWorksOffline('maplibre-gl', '')).toBe(false);
|
||||
expect(glWorksOffline('mapbox-gl', OFM_POSITRON)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a stored raster template on the web, never in the iOS app or for OSM', () => {
|
||||
expect(leafletWorksOffline(OFM_POSITRON, true)).toBe(true);
|
||||
expect(leafletWorksOffline('https://tiles.openfreemap.org/styles/liberty', false)).toBe(false);
|
||||
expect(leafletWorksOffline('https://tiles.example.com/{z}/{x}/{y}.png', false)).toBe(true);
|
||||
expect(leafletWorksOffline('https://tiles.example.com/{z}/{x}/{y}.png', true)).toBe(false);
|
||||
expect(leafletWorksOffline('https://tile.openstreetmap.org/{z}/{x}/{y}.png', false)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults';
|
||||
import { isNativeIos } from '../../native/platform';
|
||||
import { isVectorStyle } from '../../utils/tileUrl';
|
||||
import { normalizeStyleForProvider, type GlMapProvider } from './glProviders';
|
||||
|
||||
/**
|
||||
* The basemap a map falls back to offline, so that whatever the user picked
|
||||
* online (Mapbox, a raster template, another OpenFreeMap style) they never
|
||||
* face a grey map once the connection goes.
|
||||
*
|
||||
* The two OpenFreeMap styles below are the ones "prepare for offline" stores
|
||||
* for every trip, whatever the configured provider (sync/tilePrefetcher.ts):
|
||||
* both share one planet tile set, so the dark one only adds its style, sprite
|
||||
* and glyphs. A fallback has to be exactly a style that was stored, or its
|
||||
* sprite and labels are missing offline.
|
||||
*/
|
||||
export const OFFLINE_STYLES: readonly string[] = [OFM_POSITRON, OFM_DARK];
|
||||
|
||||
/** The stored style that matches the colour scheme. */
|
||||
export function offlineStyleFor(dark: boolean): string {
|
||||
return dark ? OFM_DARK : OFM_POSITRON;
|
||||
}
|
||||
|
||||
/** Hosts whose tiles may not be stored for offline use at all (OSM tile usage policy). */
|
||||
const NO_OFFLINE_HOSTS = ['tile.openstreetmap.org'];
|
||||
|
||||
export function isOfflineForbiddenTemplate(template: string): boolean {
|
||||
try {
|
||||
const host = new URL(template.replace(/\{s\}/g, 'a')).hostname;
|
||||
return NO_OFFLINE_HOSTS.some((h) => host === h || host.endsWith(`.${h}`));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a GL map can be drawn offline as configured. Only MapLibre on one of
|
||||
* the stored styles can: Mapbox has nothing stored to draw from, and another
|
||||
* OpenFreeMap style lacks its sprite and glyphs offline.
|
||||
*/
|
||||
export function glWorksOffline(provider: GlMapProvider, maplibreStyle: string | null | undefined): boolean {
|
||||
return provider === 'maplibre-gl' && OFFLINE_STYLES.includes(normalizeStyleForProvider('maplibre-gl', maplibreStyle));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the Leaflet map's basemap can be drawn offline as configured. A
|
||||
* stored vector style can. A raster template can where the service worker
|
||||
* serves the prefetched tiles, which the iOS app's WebView does not run for a
|
||||
* self-hosted server, and never for a host whose tiles may not be stored.
|
||||
*/
|
||||
export function leafletWorksOffline(tileUrl: string, nativeIos: boolean = isNativeIos()): boolean {
|
||||
if (isVectorStyle(tileUrl)) return OFFLINE_STYLES.includes(tileUrl);
|
||||
return !nativeIos && !isOfflineForbiddenTemplate(tileUrl);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useSharedImportStore } from '../../native/sharedFiles'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Upload, X } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -35,6 +36,12 @@ export default function BookingImportModal({ isOpen, onClose, tripId, kind }: Bo
|
||||
const mouseDownTarget = useRef<EventTarget | null>(null)
|
||||
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
// A booking document shared to TREK lands here already chosen.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const shared = useSharedImportStore.getState().file
|
||||
if (shared) setFiles([useSharedImportStore.getState().take() ?? shared])
|
||||
}, [isOpen])
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
@@ -475,9 +475,8 @@ describe('PlaceDetailsColumn — hours and rating', () => {
|
||||
* FE-PDC-027 — nothing came back for this place.
|
||||
*
|
||||
* It used to be a line of grey text plus, when no Google key was set, a panel
|
||||
* telling the reader to go and ask an administrator for one. The mascot says the
|
||||
* same thing in the shape every other empty state in TREK uses, and whose key is
|
||||
* missing is not this panel's business.
|
||||
* telling the reader to go and ask an administrator for one. One quiet line says
|
||||
* that nothing came back, and whose key is missing is not this panel's business.
|
||||
*/
|
||||
describe('PlaceDetailsColumn — nothing found', () => {
|
||||
it('FE-PDC-027: shows the empty state once, and no advice about API keys', async () => {
|
||||
|
||||
@@ -26,7 +26,6 @@ import { mapsApi } from '../../api/client'
|
||||
import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex } from './placeOpenState'
|
||||
import { convertHoursLine, isUnknownHoursLine, splitHoursLine } from './placeHoursFormat'
|
||||
import { safeHttpUrl } from '../../utils/safeUrl'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { LABEL } from '../shared/dialogParts'
|
||||
@@ -66,8 +65,8 @@ interface PlaceDetailsColumnProps {
|
||||
fluid?: boolean
|
||||
/**
|
||||
* 'dialog' is the desktop add place dialog: a grey panel of white cards under
|
||||
* small eyebrows, and a quiet hint card where the sheet shows the mascot. The
|
||||
* default is the phone sheet's look, which this prop leaves exactly as it was.
|
||||
* small eyebrows. The default is the phone sheet's look: a card in the mobile
|
||||
* tokens with the same one-line hints.
|
||||
*/
|
||||
variant?: 'sheet' | 'dialog'
|
||||
/** False on an instance with no Google key, which is most of them. */
|
||||
@@ -76,13 +75,24 @@ interface PlaceDetailsColumnProps {
|
||||
|
||||
type LoadState = 'idle' | 'loading' | 'ready' | 'error'
|
||||
|
||||
/** The phone sheet's quiet one-line state: an icon and a sentence, no mascot taking half the screen. */
|
||||
function SheetHint({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
|
||||
return (
|
||||
<p className="flex items-center gap-2 rounded-[12px] bg-[color:var(--m-ic)] px-3 py-2.5 text-caption text-m-muted">
|
||||
<span className="flex-none">{icon}</span>
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Overline label, the section heading Vacay and the dashboard widgets share.
|
||||
* Uppercase with wide tracking, muted, no rule underneath.
|
||||
*/
|
||||
function Overline({ children }: { children: React.ReactNode }): React.ReactElement {
|
||||
return (
|
||||
<p className="text-caption font-semibold uppercase tracking-[0.14em] text-content-faint">{children}</p>
|
||||
// The phone form's eyebrow (mobile Eyebrow), so these labels match Name, Notes and the rest below.
|
||||
<p className="font-geist text-[length:calc(10px*var(--fs-scale-caption,1))] font-bold uppercase tracking-[.09em] text-m-faint">{children}</p>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -93,7 +103,7 @@ function Overline({ children }: { children: React.ReactNode }): React.ReactEleme
|
||||
function Block({ label, dialog, children }: { label: React.ReactNode; dialog: boolean; children: React.ReactNode }): React.ReactElement {
|
||||
if (!dialog) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-[5px]">
|
||||
<Overline>{label}</Overline>
|
||||
{children}
|
||||
</div>
|
||||
@@ -126,6 +136,9 @@ export default function PlaceDetailsColumn({
|
||||
const adoptBlockedId = useId()
|
||||
const [data, setData] = useState<MapsPlaceEnrichmentResult | null>(null)
|
||||
const [state, setState] = useState<LoadState>('idle')
|
||||
// The phone sheet folds the card away to give the form's own fields the room.
|
||||
const [open, setOpen] = useState(true)
|
||||
const bodyId = useId()
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const selectionKey = selection ? cacheKeyFor(selection, language) : null
|
||||
@@ -225,17 +238,36 @@ export default function PlaceDetailsColumn({
|
||||
// tiles, which is too small to tell a facade from a foyer. The column is
|
||||
// stretched to the form's height by the row it sits in, so the extra room
|
||||
// costs nothing that was being used.
|
||||
<aside className={dialog ? `${SIDE_COLUMN} ${fluid ? '' : 'sm:w-80'}` : `w-full ${fluid ? '' : 'sm:w-80'} shrink-0 flex flex-col rounded-xl border border-edge bg-surface-secondary overflow-hidden self-stretch`}>
|
||||
<aside className={dialog ? `${SIDE_COLUMN} ${fluid ? '' : 'sm:w-80'}` : `w-full ${fluid ? '' : 'sm:w-80'} shrink-0 flex flex-col rounded-[18px] border border-[color:var(--m-cbr)] bg-[color:var(--m-card)] overflow-hidden self-stretch`}>
|
||||
{dialog ? (
|
||||
<ColumnHead icon={<Landmark size={13} strokeWidth={2.2} />}>{t('places.details.title')}</ColumnHead>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-edge shrink-0">
|
||||
<Landmark size={15} className="text-accent" />
|
||||
<span className="text-body font-semibold text-content">{t('places.details.title')}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={bodyId}
|
||||
onClick={() => setOpen(o => !o)}
|
||||
className={`flex w-full shrink-0 items-center gap-2.5 px-3 py-2.5 text-start ${open ? 'border-b border-[color:var(--m-rowbr)]' : ''}`}
|
||||
>
|
||||
<span className="flex h-8 w-8 flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-ink">
|
||||
<Landmark size={15} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 text-body font-semibold text-m-ink">{t('places.details.title')}</span>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
strokeWidth={2.2}
|
||||
className={`flex-none text-m-muted transition-transform duration-200 ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className={dialog ? 'flex min-h-0 flex-1 flex-col gap-3.5 overflow-y-auto px-3 pb-3' : 'flex-1 min-h-0 overflow-y-auto p-3 space-y-3.5'}>
|
||||
{/* Folding on the phone: the grid row eases between its height and none. */}
|
||||
<div
|
||||
id={bodyId}
|
||||
className={dialog ? 'contents' : `grid transition-[grid-template-rows] duration-300 ease-out ${open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'}`}
|
||||
>
|
||||
<div className={dialog ? 'flex min-h-0 flex-1 flex-col gap-3.5 overflow-y-auto px-3 pb-3' : `min-h-0 overflow-hidden ${open ? '' : 'invisible'}`}>
|
||||
<div className={dialog ? 'contents' : 'p-3 space-y-3'}>
|
||||
{/* The place's picture first: its own ones are there whatever the sources answer. */}
|
||||
<PictureBlock
|
||||
photos={state === 'ready' && !data?.disabled ? (data?.photos ?? []) : []}
|
||||
@@ -246,19 +278,13 @@ export default function PlaceDetailsColumn({
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{/* Nothing picked yet, which is a state rather than a fault: the mascot
|
||||
waits with an idle look instead of a sad one. The dialog says the
|
||||
same in a quiet card, and the column keeps its width either way. */}
|
||||
{/* Nothing picked yet, which is a state rather than a fault: one quiet
|
||||
line, in the dialog and on the phone alike, so the form's own fields
|
||||
stay in view instead of sitting under a mascot half a screen high. */}
|
||||
{!selection && (dialog ? (
|
||||
<HintCard icon={<ImageIcon size={15} strokeWidth={2} />}>{t('places.details.empty')}</HintCard>
|
||||
) : (
|
||||
<EmptyState
|
||||
scene="idle"
|
||||
title={t('places.details.empty')}
|
||||
size={92}
|
||||
fill
|
||||
surface="var(--bg-secondary)"
|
||||
/>
|
||||
<SheetHint icon={<ImageIcon size={14} strokeWidth={2} />}>{t('places.details.empty')}</SheetHint>
|
||||
))}
|
||||
|
||||
{selection && state === 'loading' && (dialog ? (
|
||||
@@ -278,22 +304,13 @@ export default function PlaceDetailsColumn({
|
||||
? <HintCard icon={<Landmark size={15} strokeWidth={2} />}>{t('places.details.disabled')}</HintCard>
|
||||
: <p className="text-caption text-content-muted">{t('places.details.disabled')}</p>)}
|
||||
|
||||
{/* The mascot rather than a line of grey text and, under it, a paragraph
|
||||
telling the reader to go and ask an administrator for a Google key.
|
||||
Nothing came back for this place, which is worth saying once and
|
||||
{/* Nothing came back for this place, which is worth saying once and
|
||||
plainly; whose key is missing is not this panel's business, and on a
|
||||
place the free sources describe fine it read as an advert. */}
|
||||
{isEmpty && (dialog ? (
|
||||
<HintCard icon={<SearchX size={15} strokeWidth={2} />}>{t('places.details.nothing')}</HintCard>
|
||||
) : (
|
||||
<EmptyState
|
||||
scene="search"
|
||||
mood="sad"
|
||||
title={t('places.details.nothing')}
|
||||
size={92}
|
||||
fill
|
||||
surface="var(--bg-secondary)"
|
||||
/>
|
||||
<SheetHint icon={<SearchX size={14} strokeWidth={2} />}>{t('places.details.nothing')}</SheetHint>
|
||||
))}
|
||||
|
||||
{selection && state === 'ready' && !data?.disabled && !isEmpty && (
|
||||
@@ -319,6 +336,8 @@ export default function PlaceDetailsColumn({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -642,7 +661,7 @@ function DescriptionBlock({
|
||||
|
||||
return (
|
||||
<Block label={aboutBrand ? t('places.details.aboutBrand') : t('places.details.description')} dialog={dialog}>
|
||||
<div className={dialog ? `${COLUMN_CARD} p-3 space-y-2` : 'rounded-lg border border-edge bg-surface p-2.5 space-y-2'}>
|
||||
<div className={dialog ? `${COLUMN_CARD} p-3 space-y-2` : 'rounded-[14px] bg-[color:var(--m-ic)] p-3 space-y-2'}>
|
||||
{aboutBrand && (
|
||||
<p className="flex items-start gap-1.5 text-caption leading-tight text-content-faint">
|
||||
<Building2 className="mt-px h-3 w-3 shrink-0" />
|
||||
@@ -674,7 +693,7 @@ function DescriptionBlock({
|
||||
onClick={onAdopt}
|
||||
disabled={adoptBlocked}
|
||||
aria-describedby={adoptBlocked ? adoptBlockedId : undefined}
|
||||
className={dialog ? WHITE_BUTTON : 'w-full inline-flex items-center justify-center gap-1.5 rounded-lg px-3 py-2 text-body font-medium bg-accent-subtle text-accent-on hover:bg-accent hover:text-accent-text disabled:opacity-50 disabled:hover:bg-accent-subtle disabled:hover:text-accent-on transition-colors'}
|
||||
className={dialog ? WHITE_BUTTON : 'inline-flex h-8 items-center justify-center gap-1.5 rounded-full bg-m-act px-3.5 text-caption font-semibold text-m-actfg transition-transform active:scale-[.97] disabled:opacity-40'}
|
||||
style={dialog ? fs(13, 'body') : undefined}
|
||||
>
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
|
||||
@@ -44,6 +44,7 @@ export function SuggestionInputBox({
|
||||
}: SuggestionInputBoxProps) {
|
||||
return (
|
||||
<div
|
||||
data-field-trigger={floating ? undefined : 'pad'}
|
||||
className={floating ? 'focus-within:ring-2 focus-within:ring-accent' : 'bg-surface-tertiary'}
|
||||
style={
|
||||
floating
|
||||
|
||||
@@ -20,7 +20,7 @@ import { formatMoney, formatTime } from '../../../utils/formatters'
|
||||
import { copyText } from '../../../utils/clipboard'
|
||||
import { fmtTransitDuration } from '../transitDisplay'
|
||||
import { parseMeta, displayTitle } from './bookingsModel'
|
||||
import { formatDay, type BookingFacts } from './bookingFacts'
|
||||
import { stayStats, type BookingFacts } from './bookingFacts'
|
||||
import { AirTrailPill, BOX, Eyebrow, Field, FileRows, ReviewPill, TravelerChips, fs, toneOf } from './bookingParts'
|
||||
import { TransitLegs, transitSummary } from './transitParts'
|
||||
import { BookingDialogHeader, StatusPill, TypePill } from './BookingDialogShell'
|
||||
@@ -79,10 +79,7 @@ export default function BookingDetailDialog(p: BookingDetailDialogProps) {
|
||||
|
||||
const stats: { value: string; label: string }[] = []
|
||||
if (facts.isHotel) {
|
||||
if (facts.startDate) stats.push({ value: formatDay(facts.startDate, locale, false), label: t('reservations.meta.checkIn') + (meta.check_in_time ? ` ${meta.check_in_time}` : '') })
|
||||
if (facts.endDate) stats.push({ value: formatDay(facts.endDate, locale, false), label: t('reservations.meta.checkOut') + (meta.check_out_time ? ` ${meta.check_out_time}` : '') })
|
||||
const nights = nightsBetween(facts.startDate, facts.endDate)
|
||||
if (nights) stats.push({ value: String(nights), label: t('reservations.nights', { count: nights }) })
|
||||
stats.push(...stayStats(facts, meta, t, locale))
|
||||
} else {
|
||||
const from = facts.endpoints[0]
|
||||
const to = facts.endpoints[facts.endpoints.length - 1]
|
||||
@@ -318,8 +315,3 @@ function DetailField({ label, children }: { label: string; children: ReactNode }
|
||||
)
|
||||
}
|
||||
|
||||
function nightsBetween(a: string | null, b: string | null): number {
|
||||
if (!a || !b) return 0
|
||||
const n = Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 864e5)
|
||||
return n > 0 ? n : 0
|
||||
}
|
||||
|
||||
@@ -133,3 +133,28 @@ export function bookingFacts(r: Reservation, c: FactsContext): BookingFacts {
|
||||
isHotel,
|
||||
}
|
||||
}
|
||||
|
||||
export function nightsBetween(a: string | null, b: string | null): number {
|
||||
if (!a || !b) return 0
|
||||
const n = Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 864e5)
|
||||
return n > 0 ? n : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* A stay's three tiles: check-in and check-out with their dates and times, and
|
||||
* the nights between. The desktop booking dialog and the phone's booking sheet
|
||||
* show the same ones.
|
||||
*/
|
||||
export function stayStats(
|
||||
facts: BookingFacts,
|
||||
meta: { check_in_time?: string | null; check_out_time?: string | null },
|
||||
t: FactsContext['t'],
|
||||
locale: string,
|
||||
): { value: string; label: string }[] {
|
||||
const stats: { value: string; label: string }[] = []
|
||||
if (facts.startDate) stats.push({ value: formatDay(facts.startDate, locale, false), label: t('reservations.meta.checkIn') + (meta.check_in_time ? ` ${meta.check_in_time}` : '') })
|
||||
if (facts.endDate) stats.push({ value: formatDay(facts.endDate, locale, false), label: t('reservations.meta.checkOut') + (meta.check_out_time ? ` ${meta.check_out_time}` : '') })
|
||||
const nights = nightsBetween(facts.startDate, facts.endDate)
|
||||
if (nights) stats.push({ value: String(nights), label: t('reservations.nights', { count: nights }) })
|
||||
return stats
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { dayRouteTargets, generateAppleMapsUrl, generateGeoUrl, generateWazeUrl } from './dayRouteApps';
|
||||
|
||||
const A = { lat: 35.71, lng: 139.79, name: 'Senso-ji' };
|
||||
const B = { lat: 35.72, lng: 139.77, name: 'Ueno Park' };
|
||||
const C = { lat: 35.66, lng: 139.7, name: 'Shibuya' };
|
||||
|
||||
function userAgent(ua: string) {
|
||||
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(ua);
|
||||
}
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('dayRouteApps', () => {
|
||||
it('opens one stop in Apple Maps as a named pin', () => {
|
||||
expect(generateAppleMapsUrl([A])).toBe('https://maps.apple.com/?ll=35.71,139.79&q=Senso-ji');
|
||||
});
|
||||
|
||||
it('hands Apple Maps the whole day, the stops between as waypoints, in the travel mode', () => {
|
||||
expect(generateAppleMapsUrl([A, B, C], 'walking')).toBe(
|
||||
'https://maps.apple.com/directions?source=35.71,139.79&destination=35.66,139.7&waypoint=35.72,139.77&mode=walking'
|
||||
);
|
||||
});
|
||||
|
||||
it('sends Waze and geo: to the first stop, since they take one place', () => {
|
||||
expect(generateWazeUrl([A, B])).toBe('https://waze.com/ul?ll=35.71,139.79&navigate=yes');
|
||||
expect(generateGeoUrl([A, B])).toBe('geo:35.71,139.79?q=35.71,139.79(Senso-ji)');
|
||||
expect(generateWazeUrl([])).toBeNull();
|
||||
});
|
||||
|
||||
it('offers Apple Maps on an iPhone and marks the one-place apps for a multi-stop day', () => {
|
||||
userAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)');
|
||||
const targets = dayRouteTargets([A, B], 'driving');
|
||||
expect(targets.map((t) => t.id)).toEqual(['google', 'apple', 'comaps', 'waze']);
|
||||
expect(targets.find((t) => t.id === 'waze')?.firstStopOnly).toBe(true);
|
||||
expect(targets.find((t) => t.id === 'google')?.firstStopOnly).toBeUndefined();
|
||||
});
|
||||
|
||||
it('offers the generic map-app link on Android instead of Apple Maps', () => {
|
||||
userAgent('Mozilla/5.0 (Linux; Android 15)');
|
||||
expect(dayRouteTargets([A], 'driving').map((t) => t.id)).toEqual(['google', 'comaps', 'waze', 'geo']);
|
||||
// A single stop is the whole day, so nothing is cut short.
|
||||
expect(dayRouteTargets([A], 'driving').some((t) => t.firstStopOnly)).toBe(false);
|
||||
});
|
||||
|
||||
it('offers nothing for a day without a located stop', () => {
|
||||
expect(dayRouteTargets([], 'driving')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
generateCoMapsUrl,
|
||||
generateGoogleMapsUrl,
|
||||
type NamedWaypoint,
|
||||
type RouteProfileKey,
|
||||
} from '../Map/RouteCalculator';
|
||||
import { showsAppleMaps, showsGeoUri, type NavigationAppId } from './placeNavigation';
|
||||
|
||||
/**
|
||||
* The map apps a day's route can be handed to, behind the plan's one Maps
|
||||
* button. Same stops as every other export (dayExportStops), so the app shows
|
||||
* the day the plan shows.
|
||||
*
|
||||
* Not every app takes a whole route. Google Maps, Apple Maps and CoMaps do (each
|
||||
* in its own way, see the builders); Waze and the generic geo: link navigate to
|
||||
* one place only, so they lead to the first stop and say so rather than quietly
|
||||
* dropping the rest of the day.
|
||||
*/
|
||||
export interface DayRouteTarget {
|
||||
id: NavigationAppId;
|
||||
/** Product name, never translated; the generic entry carries labelKey instead. */
|
||||
label: string;
|
||||
labelKey?: string;
|
||||
url: string;
|
||||
/** True when the app only gets the first stop, not the whole route. */
|
||||
firstStopOnly?: boolean;
|
||||
}
|
||||
|
||||
/** Apple Maps' directions modes. Cycling exists there; anything else drives. */
|
||||
function appleMode(profile: RouteProfileKey): string {
|
||||
if (profile === 'walking') return 'walking';
|
||||
if (profile === 'cycling') return 'cycling';
|
||||
return 'driving';
|
||||
}
|
||||
|
||||
const point = (p: NamedWaypoint) => `${p.lat},${p.lng}`;
|
||||
const label = (p: NamedWaypoint) => encodeURIComponent(p.name?.trim() || point(p));
|
||||
|
||||
/**
|
||||
* Apple Maps for a day: one stop opens as a pin with its name, more go as
|
||||
* directions with the stops between as waypoints (Apple's unified Maps URLs,
|
||||
* which iOS, macOS and the web version all read).
|
||||
*/
|
||||
export function generateAppleMapsUrl(places: NamedWaypoint[], profile: RouteProfileKey = 'driving'): string | null {
|
||||
const valid = places.filter((p) => p.lat != null && p.lng != null);
|
||||
if (valid.length === 0) return null;
|
||||
if (valid.length === 1) return `https://maps.apple.com/?ll=${point(valid[0])}&q=${label(valid[0])}`;
|
||||
const [first, ...rest] = valid;
|
||||
const last = rest[rest.length - 1];
|
||||
const between = rest
|
||||
.slice(0, -1)
|
||||
.map((p) => `&waypoint=${point(p)}`)
|
||||
.join('');
|
||||
return `https://maps.apple.com/directions?source=${point(first)}&destination=${point(last)}${between}&mode=${appleMode(profile)}`;
|
||||
}
|
||||
|
||||
/** Waze navigates to one place; the day's first stop is where the drive starts. */
|
||||
export function generateWazeUrl(places: NamedWaypoint[]): string | null {
|
||||
const first = places.find((p) => p.lat != null && p.lng != null);
|
||||
return first ? `https://waze.com/ul?ll=${point(first)}&navigate=yes` : null;
|
||||
}
|
||||
|
||||
/** The generic Android link, handed to whichever map app is installed; one place too. */
|
||||
export function generateGeoUrl(places: NamedWaypoint[]): string | null {
|
||||
const first = places.find((p) => p.lat != null && p.lng != null);
|
||||
return first ? `geo:${point(first)}?q=${point(first)}(${label(first)})` : null;
|
||||
}
|
||||
|
||||
/** Every app the day can be opened in on this device, in the order they are offered. */
|
||||
export function dayRouteTargets(stops: NamedWaypoint[], profile: RouteProfileKey): DayRouteTarget[] {
|
||||
const out: DayRouteTarget[] = [];
|
||||
const push = (target: Omit<DayRouteTarget, 'url'>, url: string | null) => {
|
||||
if (url) out.push({ ...target, url });
|
||||
};
|
||||
push({ id: 'google', label: 'Google Maps' }, generateGoogleMapsUrl(stops));
|
||||
if (showsAppleMaps()) push({ id: 'apple', label: 'Apple Maps' }, generateAppleMapsUrl(stops, profile));
|
||||
push({ id: 'comaps', label: 'CoMaps' }, generateCoMapsUrl(stops, profile));
|
||||
const located = stops.filter((p) => p.lat != null && p.lng != null).length;
|
||||
push({ id: 'waze', label: 'Waze', firstStopOnly: located > 1 }, generateWazeUrl(stops));
|
||||
if (showsGeoUri()) {
|
||||
push(
|
||||
{ id: 'geo', label: 'geo:', labelKey: 'inspector.otherMapApp', firstStopOnly: located > 1 },
|
||||
generateGeoUrl(stops)
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import { selectionHaptic } from '../../native/haptics'
|
||||
|
||||
/** The track and the knob glide with the same easing the planner's panels use. */
|
||||
const EASE = 'cubic-bezier(0.32, 0.72, 0, 1)'
|
||||
@@ -10,7 +11,7 @@ const EASE = 'cubic-bezier(0.32, 0.72, 0, 1)'
|
||||
*/
|
||||
export default function ToggleSwitch({ on, onToggle, label, describedBy, disabled = false }: { on: boolean; onToggle: () => void; label?: string; describedBy?: string; disabled?: boolean }) {
|
||||
return (
|
||||
<button type="button" onClick={onToggle} aria-pressed={on} aria-label={label} aria-describedby={describedBy}
|
||||
<button type="button" onClick={() => { selectionHaptic(); onToggle() }} aria-pressed={on} aria-label={label} aria-describedby={describedBy}
|
||||
disabled={disabled} aria-disabled={disabled || undefined}
|
||||
// A keyboard focus ring in the text colour, like the fields; the pointer gets none.
|
||||
className="group outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg-card)]"
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
Ruler,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import { createElement, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createElement, useEffect, useMemo, useRef, useState, type ComponentType } from 'react';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkBreaks from 'remark-breaks';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
@@ -38,7 +38,7 @@ import { inheritedTrackColor, resolveTrackColor } from '../Map/trackColors';
|
||||
import { BOX } from '../Planner/bookings/bookingParts';
|
||||
import { getNavigationTargets, navigationTargetLabel, openNavigationTarget } from '../Planner/placeNavigation';
|
||||
import { SoftPill, tintOf } from '../Planner/planParts';
|
||||
import DetailShell from '../shared/DetailShell';
|
||||
import DetailShell, { type DetailShellProps } from '../shared/DetailShell';
|
||||
import { DeleteButton, DialogButton, DialogSection, FooterSpacer, fs } from '../shared/DialogShell';
|
||||
import ElevationProfile from '../shared/ElevationProfile';
|
||||
import { NavigationMenu } from '../shared/NavigationMenu';
|
||||
@@ -108,6 +108,8 @@ interface TourDetailDialogProps extends TourPermissionProps {
|
||||
readOnly?: boolean;
|
||||
/** Desktop-only opener; the map detail stays non-modal and returns focus when closed. */
|
||||
desktopFocusReturnTarget?: HTMLElement | null;
|
||||
/** The frame it renders into: the desktop's card over the map, or the phone's sheet. */
|
||||
shell?: ComponentType<DetailShellProps>;
|
||||
}
|
||||
|
||||
/** The pills of the head band: white like a place's, one fact each. */
|
||||
@@ -518,6 +520,7 @@ export default function TourDetailDialog({
|
||||
desktopNonModal = false,
|
||||
readOnly = false,
|
||||
desktopFocusReturnTarget = null,
|
||||
shell: Shell = DetailShell,
|
||||
canEdit: editPermission,
|
||||
canAssign: assignPermission,
|
||||
}: TourDetailDialogProps) {
|
||||
@@ -701,7 +704,7 @@ export default function TourDetailDialog({
|
||||
);
|
||||
|
||||
return (
|
||||
<DetailShell
|
||||
<Shell
|
||||
testId="tour-detail"
|
||||
header={header}
|
||||
footer={footer}
|
||||
@@ -797,7 +800,7 @@ export default function TourDetailDialog({
|
||||
|
||||
<TourProvenanceBlock tripId={place.trip_id} tour={tour} canCheck={canEdit} onReimported={onSourceReimported} />
|
||||
<TourFilesSection placeId={place.id} files={placeFiles} onFileUpload={canEdit ? onFileUpload : undefined} />
|
||||
</DetailShell>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AlertTriangle, HelpCircle } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import { DialogTile, NEUTRAL_TINT, fs } from './DialogShell'
|
||||
import { useLastOpen, usePresence } from './usePresence'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean
|
||||
@@ -28,7 +29,14 @@ function runConfirm(onConfirm: () => void): void {
|
||||
}
|
||||
}
|
||||
|
||||
export default function ConfirmDialog({
|
||||
export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
const { mounted, closing } = usePresence(props.isOpen)
|
||||
// The question stays readable while it fades, even when its owner clears the subject on close.
|
||||
const shown = useLastOpen(props, props.isOpen)
|
||||
return mounted ? <ConfirmDialogFrame {...shown} isOpen={props.isOpen} closing={closing} /> : null
|
||||
}
|
||||
|
||||
function ConfirmDialogFrame({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
@@ -38,7 +46,8 @@ export default function ConfirmDialog({
|
||||
cancelLabel,
|
||||
danger = true,
|
||||
children,
|
||||
}: ConfirmDialogProps) {
|
||||
closing = false,
|
||||
}: ConfirmDialogProps & { closing?: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
const isPhone = useIsPhone()
|
||||
|
||||
@@ -57,14 +66,14 @@ export default function ConfirmDialog({
|
||||
return () => document.removeEventListener('keydown', handleEsc, true)
|
||||
}, [isOpen, handleEsc])
|
||||
|
||||
if (!isOpen) return null
|
||||
if (!isOpen && !closing) return null
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
// Backdrop only — Escape (above) and the Cancel button below are the
|
||||
// keyboard routes out of the dialog.
|
||||
role="presentation"
|
||||
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter bg-[rgba(15,23,42,0.5)]"
|
||||
className={`fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter bg-[rgba(15,23,42,0.5)] ${closing ? 'trek-backdrop-exit pointer-events-none' : ''}`}
|
||||
style={{ paddingBottom: 'var(--bottom-nav-h)' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
@@ -74,7 +83,7 @@ export default function ConfirmDialog({
|
||||
own, and the two buttons below it always stay in reach. */}
|
||||
<div
|
||||
role="presentation"
|
||||
className={`trek-modal-enter flex max-h-[calc(100dvh-2rem-var(--bottom-nav-h,0px))] flex-col rounded-2xl shadow-2xl w-full ${children ? 'max-w-md' : 'max-w-sm'} p-6 bg-surface-card`}
|
||||
className={`trek-modal-enter ${closing ? 'trek-modal-exit' : ''} flex max-h-[calc(100dvh-2rem-var(--bottom-nav-h,0px))] flex-col rounded-2xl shadow-2xl w-full ${children ? 'max-w-md' : 'max-w-sm'} p-6 bg-surface-card`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex min-h-0 items-start gap-4 overflow-y-auto overscroll-contain">
|
||||
@@ -117,7 +126,7 @@ export default function ConfirmDialog({
|
||||
// with its tile, the message below, the answers in a bar at the foot.
|
||||
<div
|
||||
role="presentation"
|
||||
className={`trek-modal-enter flex max-h-[calc(100dvh-2rem-var(--bottom-nav-h,0px))] w-full flex-col overflow-hidden rounded-[22px] bg-surface-card shadow-2xl ${children ? 'max-w-md' : 'max-w-sm'}`}
|
||||
className={`trek-modal-enter ${closing ? 'trek-modal-exit' : ''} flex max-h-[calc(100dvh-2rem-var(--bottom-nav-h,0px))] w-full flex-col overflow-hidden rounded-[22px] bg-surface-card shadow-2xl ${children ? 'max-w-md' : 'max-w-sm'}`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className={`flex flex-none items-center gap-3.5 px-6 pb-4 pt-5 ${danger ? 'bg-danger-soft' : ''}`} style={danger ? undefined : { background: NEUTRAL_TINT }}>
|
||||
|
||||
@@ -101,12 +101,15 @@ describe('ContextMenu', () => {
|
||||
expect(onClose).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('FE-W4CTX-010: flips the menu back inside the viewport when it would overflow', () => {
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||
width: 200, height: 120, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
function layoutSize(width: number, height: number) {
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(width)
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(height)
|
||||
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 400 })
|
||||
Object.defineProperty(window, 'innerHeight', { writable: true, configurable: true, value: 300 })
|
||||
}
|
||||
|
||||
it('FE-W4CTX-010: flips the menu back inside the viewport when it would overflow', () => {
|
||||
layoutSize(200, 120)
|
||||
|
||||
const { baseElement } = render(<ContextMenu menu={{ x: 380, y: 290, items: [{ label: 'Open' }] }} onClose={() => {}} />)
|
||||
|
||||
@@ -115,6 +118,18 @@ describe('ContextMenu', () => {
|
||||
expect(menu.style.top).toBe('172px')
|
||||
})
|
||||
|
||||
it('FE-W4CTX-010b: keeps an end-aligned menu off the left edge, and caps one taller than the screen', () => {
|
||||
layoutSize(200, 500)
|
||||
|
||||
const { baseElement } = render(<ContextMenu menu={{ x: 40, y: 20, alignEnd: true, items: [{ label: 'Open' }] }} onClose={() => {}} />)
|
||||
|
||||
const menu = baseElement.querySelector('.trek-popover-enter') as HTMLElement
|
||||
expect(menu.style.left).toBe('8px')
|
||||
expect(menu.style.top).toBe('8px')
|
||||
expect(menu.style.maxHeight).toBe('284px')
|
||||
expect(menu.style.overflowY).toBe('auto')
|
||||
})
|
||||
|
||||
it('FE-W4CTX-011: Escape, a scroll and a resize close it', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<ContextMenu menu={{ x: 0, y: 0, items: [{ label: 'Open' }] }} onClose={onClose} />)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect, useLayoutEffect, useRef, type ComponentType, type CSSProperties } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { LucideIcon } from 'lucide-react'
|
||||
import { useLastOpen, usePresence } from './usePresence'
|
||||
|
||||
interface MenuItem {
|
||||
label?: string
|
||||
@@ -50,6 +51,13 @@ interface ContextMenuProps {
|
||||
}
|
||||
|
||||
export function ContextMenu({ menu, onClose }: ContextMenuProps) {
|
||||
const { mounted, closing } = usePresence(menu != null, 130)
|
||||
// The items stay on screen while the menu fades, after its owner set it to null.
|
||||
const shown = useLastOpen(menu, menu != null)
|
||||
return mounted && shown ? <ContextMenuFrame menu={shown} onClose={onClose} closing={closing} /> : null
|
||||
}
|
||||
|
||||
function ContextMenuFrame({ menu, onClose, closing }: { menu: MenuState; onClose: () => void; closing: boolean }) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -97,21 +105,31 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
|
||||
useLayoutEffect(() => {
|
||||
if (!menu || !ref.current) return
|
||||
const el = ref.current
|
||||
const rect = el.getBoundingClientRect()
|
||||
// The layout size, not getBoundingClientRect: the menu is still mid enter
|
||||
// animation (scaled down) here, and the smaller box let a menu opened near an
|
||||
// edge end up partly off screen.
|
||||
const width = el.offsetWidth
|
||||
const viewW = window.innerWidth
|
||||
const viewH = window.innerHeight
|
||||
// Taller than the screen (a long list on a phone): cap it and let it scroll.
|
||||
if (el.offsetHeight > viewH - 16) {
|
||||
el.style.maxHeight = `${viewH - 16}px`
|
||||
el.style.overflowY = 'auto'
|
||||
}
|
||||
const height = Math.min(el.offsetHeight, viewH - 16)
|
||||
let { x, y } = menu
|
||||
if (menu.alignEnd) x = Math.max(8, x - rect.width)
|
||||
if (x + rect.width > window.innerWidth - 8) x = window.innerWidth - rect.width - 8
|
||||
if (y + rect.height > window.innerHeight - 8) y = window.innerHeight - rect.height - 8
|
||||
if (menu.alignEnd) x = x - width
|
||||
// Kept inside all four edges with 8px to spare.
|
||||
x = Math.min(Math.max(8, x), viewW - width - 8)
|
||||
y = Math.min(Math.max(8, y), viewH - height - 8)
|
||||
if (x !== menu.x || y !== menu.y) {
|
||||
el.style.left = `${x}px`
|
||||
el.style.top = `${y}px`
|
||||
el.style.left = `${Math.max(8, x)}px`
|
||||
el.style.top = `${Math.max(8, y)}px`
|
||||
}
|
||||
}, [menu])
|
||||
|
||||
if (!menu) return null
|
||||
|
||||
return createPortal(
|
||||
<div ref={ref} role="menu" tabIndex={-1} onKeyDown={moveFocus} className="trek-popover-enter" style={{
|
||||
<div ref={ref} role="menu" tabIndex={-1} onKeyDown={moveFocus} className={`trek-popover-enter ${closing ? 'trek-popover-exit pointer-events-none' : ''}`} style={{
|
||||
position: 'fixed', left: menu.x, top: menu.y, zIndex: 999999,
|
||||
background: 'var(--bg-card)', borderRadius: 10, padding: '4px',
|
||||
border: '1px solid var(--border-primary)',
|
||||
|
||||
@@ -298,6 +298,7 @@ export function CustomDatePicker({
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'stretch' }}>
|
||||
{/* Calendar trigger */}
|
||||
<button
|
||||
data-field-trigger={compact || borderless ? undefined : ''}
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
disabled={disabled}
|
||||
@@ -345,6 +346,7 @@ export function CustomDatePicker({
|
||||
{/* Keyboard / text-input trigger (non-compact only; compact gets it in the popup footer) */}
|
||||
{!compact && !borderless && (
|
||||
<button
|
||||
data-field-trigger=""
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTextInput(inputValue || '');
|
||||
|
||||
@@ -186,6 +186,7 @@ export default function CustomSelect({
|
||||
<button
|
||||
ref={triggerRef}
|
||||
id={id}
|
||||
data-field-trigger=""
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
aria-expanded={open}
|
||||
|
||||
@@ -129,7 +129,7 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative', ...style }}>
|
||||
<div style={{
|
||||
<div data-field-trigger="" style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', gap: 0,
|
||||
borderRadius: 10, border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-input)', overflow: 'hidden',
|
||||
|
||||
@@ -4,7 +4,7 @@ import { NEUTRAL_TINT } from './DialogShell'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import { centredMapCard } from '../Map/centredMapCard'
|
||||
|
||||
interface DetailShellProps {
|
||||
export interface DetailShellProps {
|
||||
header: ReactNode
|
||||
children: ReactNode
|
||||
footer?: ReactNode
|
||||
|
||||
@@ -205,7 +205,20 @@ describe('ErrorBoundary', () => {
|
||||
);
|
||||
// --m-* only resolves inside MobileShell's .m-root; the desktop classes would
|
||||
// render an invisible card there.
|
||||
expect(screen.getByRole('alert').className).toContain('bg-m-card');
|
||||
expect(screen.getByRole('alert').querySelector('.bg-m-card')).not.toBeNull();
|
||||
expect(screen.getByRole('alert').querySelector('.bg-surface-card')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-ERRBOUND-014b: the technical message stays for an issue report, folded away', () => {
|
||||
render(
|
||||
<ErrorBoundary boundaryId="route" level="route">
|
||||
<Boom />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
// The message stays in the page for an issue report, inside a closed details.
|
||||
const details = screen.getByText('kaboom').closest('details') as HTMLDetailsElement;
|
||||
expect(details).not.toBeNull();
|
||||
expect(details.open).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { AlertTriangle, RefreshCw, RotateCcw } from 'lucide-react'
|
||||
import { AlertTriangle, RefreshCw, RotateCcw, Sparkles } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { isChunkLoadError, reloadOnceForChunk, reloadOntoCurrentBuild } from '../../utils/chunkReload'
|
||||
|
||||
@@ -131,55 +131,74 @@ export function ErrorFallback({ error, reset, isChunkError, level, variant, labe
|
||||
: isPanel ? t('common.errorPanelBody') : t('common.errorBody')
|
||||
|
||||
const message = error instanceof Error ? error.message : null
|
||||
const Icon = isChunkError ? Sparkles : AlertTriangle
|
||||
const muted = mobile ? 'text-m-muted' : 'text-content-secondary'
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className={[
|
||||
'flex flex-col items-center justify-center gap-3 rounded-xl border p-6 text-center',
|
||||
isPanel ? 'h-full min-h-[180px]' : 'm-6 min-h-[240px]',
|
||||
mobile ? 'border-[color:var(--m-rowbr)] bg-m-card text-m-ink' : 'border-edge bg-surface-card',
|
||||
'flex flex-col items-center justify-center text-center',
|
||||
isPanel ? 'h-full min-h-[180px] p-4' : 'min-h-[60vh] p-6',
|
||||
].join(' ')}
|
||||
>
|
||||
<AlertTriangle size={22} className={mobile ? 'text-m-muted' : 'text-content-muted'} aria-hidden />
|
||||
<div className="space-y-1">
|
||||
<p className={`text-subtitle font-semibold ${mobile ? 'text-m-ink' : 'text-content'}`}>{title}</p>
|
||||
{label && <p className={`text-body ${mobile ? 'text-m-muted' : 'text-content-secondary'}`}>{label}</p>}
|
||||
<p className={`text-body ${mobile ? 'text-m-muted' : 'text-content-secondary'}`}>{body}</p>
|
||||
</div>
|
||||
{/* One calm card, the way a native app reports it: a round symbol, a short
|
||||
title and line, and one clear action. */}
|
||||
<div
|
||||
className={[
|
||||
'flex w-full max-w-sm flex-col items-center gap-4 rounded-[24px] border px-6 py-7',
|
||||
'shadow-[0_18px_48px_-24px_rgba(0,0,0,.35)]',
|
||||
mobile ? 'border-[color:var(--m-rowbr)] bg-m-card text-m-ink' : 'border-edge bg-surface-card',
|
||||
].join(' ')}
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
'flex h-14 w-14 items-center justify-center rounded-full',
|
||||
isChunkError ? 'bg-accent text-accent-text' : mobile ? 'bg-[color:var(--m-ic)] text-m-muted' : 'bg-surface-secondary text-content-muted',
|
||||
].join(' ')}
|
||||
>
|
||||
<Icon size={24} strokeWidth={2} aria-hidden />
|
||||
</span>
|
||||
<div className="space-y-1.5">
|
||||
<p className={`text-title font-semibold ${mobile ? 'text-m-ink' : 'text-content'}`}>{title}</p>
|
||||
{label && <p className={`text-body ${muted}`}>{label}</p>}
|
||||
<p className={`text-body ${muted}`}>{body}</p>
|
||||
</div>
|
||||
|
||||
{/* Worth showing: TREK is self-hosted, so the person seeing this is often the
|
||||
same person who will paste it into an issue. */}
|
||||
{message && (
|
||||
<code className={`max-w-full truncate text-caption ${mobile ? 'text-m-faint' : 'text-content-faint'}`}>
|
||||
{message}
|
||||
</code>
|
||||
)}
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
{(!isPanel || isChunkError) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reloadPage(isChunkError)}
|
||||
className="inline-flex w-full items-center justify-center gap-2 rounded-full bg-accent px-4 py-3 text-body font-semibold text-accent-text transition-transform active:scale-[.98]"
|
||||
>
|
||||
<RefreshCw size={16} aria-hidden />
|
||||
{t('common.errorReload')}
|
||||
</button>
|
||||
)}
|
||||
{/* No retry for a dead chunk — React.lazy would throw again immediately. */}
|
||||
{!isChunkError && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className={[
|
||||
'inline-flex w-full items-center justify-center gap-2 rounded-full border px-4 py-3 text-body font-semibold transition-transform active:scale-[.98]',
|
||||
mobile ? 'border-[color:var(--m-rowbr)] text-m-ink' : 'border-edge text-content',
|
||||
].join(' ')}
|
||||
>
|
||||
<RotateCcw size={16} aria-hidden />
|
||||
{t('common.errorRetry')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center gap-2 pt-1">
|
||||
{/* No retry for a dead chunk — React.lazy would throw again immediately. */}
|
||||
{!isChunkError && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-accent px-3 py-2 text-caption font-medium text-white"
|
||||
>
|
||||
<RotateCcw size={14} aria-hidden />
|
||||
{t('common.errorRetry')}
|
||||
</button>
|
||||
)}
|
||||
{(!isPanel || isChunkError) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reloadPage(isChunkError)}
|
||||
className={[
|
||||
'inline-flex items-center gap-1.5 rounded-lg border px-3 py-2 text-caption font-medium',
|
||||
mobile ? 'border-[color:var(--m-rowbr)] text-m-ink' : 'border-edge text-content',
|
||||
].join(' ')}
|
||||
>
|
||||
<RefreshCw size={14} aria-hidden />
|
||||
{t('common.errorReload')}
|
||||
</button>
|
||||
{/* Kept, folded away: TREK is self-hosted, so the person seeing this is
|
||||
often the same person who will paste it into an issue. */}
|
||||
{message && (
|
||||
<details className={`w-full text-caption ${mobile ? 'text-m-faint' : 'text-content-faint'}`}>
|
||||
<summary className="cursor-pointer select-none">{t('common.errorDetails')}</summary>
|
||||
<code className="mt-2 block max-w-full break-words text-start">{message}</code>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -193,30 +212,42 @@ export function ErrorFallback({ error, reset, isChunkError, level, variant, labe
|
||||
*/
|
||||
export function RootErrorFallback({ error, isChunkError }: FallbackState) {
|
||||
const message = error instanceof Error ? error.message : null
|
||||
const Icon = isChunkError ? Sparkles : AlertTriangle
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex min-h-dvh flex-col items-center justify-center gap-3 p-6 text-center"
|
||||
className="flex min-h-dvh flex-col items-center justify-center p-6 text-center"
|
||||
style={{ fontFamily: 'var(--font-system)' }}
|
||||
>
|
||||
<AlertTriangle size={24} className="text-content-muted" aria-hidden />
|
||||
<p className="text-subtitle font-semibold text-content">
|
||||
{isChunkError ? 'A new version is available' : 'TREK could not start'}
|
||||
</p>
|
||||
<p className="text-body text-content-secondary">
|
||||
{isChunkError
|
||||
? 'TREK was updated while this tab was open. Reload to get the new version.'
|
||||
: 'Reloading usually fixes this. Your data is safe.'}
|
||||
</p>
|
||||
{message && <code className="max-w-full truncate text-caption text-content-faint">{message}</code>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reloadPage(isChunkError)}
|
||||
className="mt-1 inline-flex items-center gap-1.5 rounded-lg bg-accent px-3 py-2 text-caption font-medium text-white"
|
||||
>
|
||||
<RefreshCw size={14} aria-hidden />
|
||||
Reload page
|
||||
</button>
|
||||
<div className="flex w-full max-w-sm flex-col items-center gap-4 rounded-[24px] border border-edge bg-surface-card px-6 py-7 shadow-[0_18px_48px_-24px_rgba(0,0,0,.35)]">
|
||||
<span className={`flex h-14 w-14 items-center justify-center rounded-full ${isChunkError ? 'bg-accent text-accent-text' : 'bg-surface-secondary text-content-muted'}`}>
|
||||
<Icon size={24} strokeWidth={2} aria-hidden />
|
||||
</span>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-title font-semibold text-content">
|
||||
{isChunkError ? 'A new version is available' : 'TREK could not start'}
|
||||
</p>
|
||||
<p className="text-body text-content-secondary">
|
||||
{isChunkError
|
||||
? 'TREK was updated while this tab was open. Reload to get the new version.'
|
||||
: 'Reloading usually fixes this. Your data is safe.'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reloadPage(isChunkError)}
|
||||
className="inline-flex w-full items-center justify-center gap-2 rounded-full bg-accent px-4 py-3 text-body font-semibold text-accent-text transition-transform active:scale-[.98]"
|
||||
>
|
||||
<RefreshCw size={16} aria-hidden />
|
||||
Reload page
|
||||
</button>
|
||||
{message && (
|
||||
<details className="w-full text-caption text-content-faint">
|
||||
<summary className="cursor-pointer select-none">Details</summary>
|
||||
<code className="mt-2 block max-w-full break-words text-start">{message}</code>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { lockBodyScroll } from '../../utils/bodyScrollLock'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import { focusDialog, trapTab } from './dialogFocus'
|
||||
import { useLastOpen, usePresence } from './usePresence'
|
||||
|
||||
const sizeClasses: Record<string, string> = {
|
||||
sm: 'max-w-sm',
|
||||
@@ -36,7 +37,11 @@ interface ModalProps {
|
||||
}
|
||||
|
||||
export default function Modal({ isOpen, ...frame }: ModalProps) {
|
||||
return isOpen ? <ModalFrame {...frame} /> : null
|
||||
const { mounted, closing } = usePresence(isOpen)
|
||||
// While it leaves, the dialog shows what it showed last: its owner often
|
||||
// clears the item in the same step that closes it.
|
||||
const shown = useLastOpen(frame, isOpen)
|
||||
return mounted ? <ModalFrame {...shown} closing={closing} /> : null
|
||||
}
|
||||
|
||||
function ModalFrame({
|
||||
@@ -47,7 +52,8 @@ function ModalFrame({
|
||||
footer,
|
||||
hideCloseButton = false,
|
||||
align = 'center',
|
||||
}: Omit<ModalProps, 'isOpen'>) {
|
||||
closing = false,
|
||||
}: Omit<ModalProps, 'isOpen'> & { closing?: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const mouseDownTarget = useRef<EventTarget | null>(null)
|
||||
@@ -55,8 +61,8 @@ function ModalFrame({
|
||||
const [focusedBefore] = useState(() => document.activeElement)
|
||||
|
||||
const handleEsc = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}, [onClose])
|
||||
if (e.key === 'Escape' && !closing) onClose()
|
||||
}, [onClose, closing])
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleEsc)
|
||||
@@ -85,7 +91,7 @@ function ModalFrame({
|
||||
// click-away, the panel only keeps that click from reaching it. Escape
|
||||
// and the header's close button are the keyboard route out.
|
||||
role="presentation"
|
||||
className={`fixed inset-0 z-[10000] flex items-start ${align === 'top' ? '' : 'sm:items-center'} justify-center px-4 trek-modal-backdrop trek-backdrop-enter bg-[rgba(15,23,42,0.5)]`}
|
||||
className={`fixed inset-0 z-[10000] flex items-start ${align === 'top' ? '' : 'sm:items-center'} justify-center px-4 trek-modal-backdrop trek-backdrop-enter bg-[rgba(15,23,42,0.5)] ${closing ? 'trek-backdrop-exit pointer-events-none' : ''}`}
|
||||
style={{ paddingTop: 70, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflow: 'hidden' }}
|
||||
onMouseDown={e => { mouseDownTarget.current = e.target }}
|
||||
onClick={e => {
|
||||
@@ -99,7 +105,7 @@ function ModalFrame({
|
||||
tabIndex={-1}
|
||||
onKeyDown={e => { if (panelRef.current) trapTab(e, panelRef.current) }}
|
||||
className={`
|
||||
trek-modal-enter outline-none
|
||||
trek-modal-enter ${closing ? 'trek-modal-exit' : ''} outline-none
|
||||
rounded-2xl overflow-hidden shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
|
||||
flex flex-col
|
||||
max-h-[calc(100dvh-var(--bottom-nav-h)-90px)] sm:max-h-[calc(100dvh-90px)]
|
||||
|
||||
@@ -22,15 +22,21 @@ export function useDiscardGuard(guard: unknown, onClose: () => void) {
|
||||
if (guardRef.current !== undefined && baseline.current === null) baseline.current = snapshot(guardRef.current)
|
||||
}, [])
|
||||
|
||||
/** Whether a way out right now would stop to ask rather than close. */
|
||||
const wouldAsk = useCallback(
|
||||
() => guardRef.current !== undefined && baseline.current !== null && snapshot(guardRef.current) !== baseline.current,
|
||||
[],
|
||||
)
|
||||
|
||||
const requestClose = useCallback(() => {
|
||||
if (guardRef.current !== undefined && baseline.current !== null && snapshot(guardRef.current) !== baseline.current) setAsking(true)
|
||||
if (wouldAsk()) setAsking(true)
|
||||
else onClose()
|
||||
}, [onClose])
|
||||
}, [onClose, wouldAsk])
|
||||
|
||||
const keepEditing = useCallback(() => setAsking(false), [])
|
||||
const discard = useCallback(() => { setAsking(false); onClose() }, [onClose])
|
||||
/** Forget the starting point, for a surface that stays mounted between openings. */
|
||||
const reset = useCallback(() => { baseline.current = null; setAsking(false) }, [])
|
||||
|
||||
return { asking, markStart, requestClose, keepEditing, discard, reset }
|
||||
return { asking, markStart, requestClose, wouldAsk, keepEditing, discard, reset }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/** The hook outside the test mode, where it really lingers for the exit animation. */
|
||||
async function loadAnimated() {
|
||||
vi.resetModules();
|
||||
vi.stubEnv('MODE', 'production');
|
||||
return import('./usePresence');
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('usePresence', () => {
|
||||
it('stays mounted and closing for the exit, then unmounts', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { usePresence } = await loadAnimated();
|
||||
const hook = renderHook(({ open }) => usePresence(open, 170), { initialProps: { open: true } });
|
||||
expect(hook.result.current).toEqual({ mounted: true, closing: false });
|
||||
|
||||
hook.rerender({ open: false });
|
||||
expect(hook.result.current).toEqual({ mounted: true, closing: true });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(170);
|
||||
});
|
||||
expect(hook.result.current).toEqual({ mounted: false, closing: false });
|
||||
});
|
||||
|
||||
it('never mounts something that was never open, and reopens straight from closing', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { usePresence } = await loadAnimated();
|
||||
const hook = renderHook(({ open }) => usePresence(open, 170), { initialProps: { open: false } });
|
||||
expect(hook.result.current.mounted).toBe(false);
|
||||
|
||||
hook.rerender({ open: true });
|
||||
hook.rerender({ open: false });
|
||||
hook.rerender({ open: true });
|
||||
expect(hook.result.current).toEqual({ mounted: true, closing: false });
|
||||
});
|
||||
|
||||
it('closes at once under the unit tests', async () => {
|
||||
vi.resetModules();
|
||||
const { usePresence } = await import('./usePresence');
|
||||
const hook = renderHook(({ open }) => usePresence(open), { initialProps: { open: true } });
|
||||
hook.rerender({ open: false });
|
||||
expect(hook.result.current.mounted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useLastOpen', () => {
|
||||
it('keeps the last value seen while open', async () => {
|
||||
const { useLastOpen } = await import('./usePresence');
|
||||
const hook = renderHook(({ value, open }) => useLastOpen(value, open), {
|
||||
initialProps: { value: 'Delete Paris?' as string | null, open: true },
|
||||
});
|
||||
hook.rerender({ value: null, open: false });
|
||||
expect(hook.result.current).toBe('Delete Paris?');
|
||||
hook.rerender({ value: 'Delete Rome?', open: true });
|
||||
expect(hook.result.current).toBe('Delete Rome?');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* How long a closing dialog, confirm or menu stays for its exit animation
|
||||
* (index.css). None under the unit tests: hundreds of them close a confirm and
|
||||
* check at once that it is gone, which is what the user sees once the fade ends.
|
||||
*/
|
||||
export const EXIT_MS = import.meta.env.MODE === 'test' ? 0 : 170;
|
||||
|
||||
/**
|
||||
* Keeps something on screen for its exit animation after it was told to close:
|
||||
* `mounted` stays true for EXIT_MS once `open` turns false, with `closing` set
|
||||
* meanwhile so the caller can add the exit class and stop taking clicks.
|
||||
*/
|
||||
export function usePresence(open: boolean, ms: number = EXIT_MS): { mounted: boolean; closing: boolean } {
|
||||
const animated = ms > 0 && EXIT_MS > 0;
|
||||
const [lingering, setLingering] = useState(false);
|
||||
const wasOpen = useRef(open);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
wasOpen.current = true;
|
||||
setLingering(false);
|
||||
return;
|
||||
}
|
||||
if (!wasOpen.current) return;
|
||||
wasOpen.current = false;
|
||||
if (!animated) return;
|
||||
setLingering(true);
|
||||
const timer = window.setTimeout(() => setLingering(false), ms);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [open, ms, animated]);
|
||||
|
||||
// The first render after closing already counts as closing, before the effect runs.
|
||||
const closing = animated && !open && (lingering || wasOpen.current);
|
||||
return { mounted: open || closing, closing };
|
||||
}
|
||||
|
||||
/**
|
||||
* The last value seen while open: what a closing popup shows during its exit,
|
||||
* since its owner often clears the data in the same step that closes it.
|
||||
*/
|
||||
export function useLastOpen<T>(value: T, open: boolean): T {
|
||||
const last = useRef(value);
|
||||
if (open) last.current = value;
|
||||
return open ? value : last.current;
|
||||
}
|
||||
@@ -568,6 +568,42 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti
|
||||
.trek-backdrop-enter {
|
||||
animation: trek-backdrop-enter 180ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
/* Closing: the mirror of each entrance, shorter and easing in, so a dialog,
|
||||
confirm or menu leaves as gently as it came (components/shared/usePresence.ts
|
||||
keeps it mounted for EXIT_MS). Added next to the entrance class rather than
|
||||
instead of it, because on phones the entrance class also docks the dialog. */
|
||||
@keyframes trek-modal-exit {
|
||||
from { opacity: 1; transform: scale(1); }
|
||||
to { opacity: 0; transform: scale(0.97); }
|
||||
}
|
||||
@keyframes trek-drawer-exit {
|
||||
from { opacity: 1; transform: translateY(0); }
|
||||
to { opacity: 0; transform: translateY(40%); }
|
||||
}
|
||||
@keyframes trek-backdrop-exit {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
.trek-modal-enter.trek-modal-exit,
|
||||
.trek-popover-enter.trek-popover-exit {
|
||||
animation: trek-modal-exit 160ms cubic-bezier(0.4, 0, 1, 1) forwards;
|
||||
}
|
||||
.trek-backdrop-enter.trek-backdrop-exit {
|
||||
animation: trek-backdrop-exit 170ms ease-in forwards;
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
.trek-modal-enter.trek-modal-exit {
|
||||
animation: trek-drawer-exit 200ms cubic-bezier(0.4, 0, 1, 1) forwards;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.trek-modal-enter.trek-modal-exit,
|
||||
.trek-popover-enter.trek-popover-exit,
|
||||
.trek-backdrop-enter.trek-backdrop-exit {
|
||||
animation: trek-backdrop-exit 120ms ease-out forwards;
|
||||
}
|
||||
}
|
||||
.trek-toast-enter {
|
||||
animation: trek-toast-enter 260ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
will-change: transform, opacity;
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { ReactNode, useRef } from 'react'
|
||||
import { useMatch } from 'react-router'
|
||||
import BottomNav from '../components/Layout/BottomNav'
|
||||
import ErrorBoundary from '../components/shared/ErrorBoundary'
|
||||
import MBottomNav from './components/MBottomNav'
|
||||
import MToastHost from './components/MToastHost'
|
||||
import MEdgeBack from './components/MEdgeBack'
|
||||
import { useNativeCapabilities } from '../native/capabilities'
|
||||
import { isNativeIos } from '../native/platform'
|
||||
import './mobile.css'
|
||||
|
||||
interface MobileShellProps {
|
||||
@@ -32,6 +35,10 @@ export default function MobileShell({ isPhone, children }: MobileShellProps) {
|
||||
// on iOS Safari a position:fixed nav paints THROUGH the trip overlay's higher
|
||||
// stacking context, so the wrong (global) bar shows on top of the trip screen.
|
||||
const inTripPlanner = useMatch('/trips/:id')
|
||||
// The app's own edge swipe back, where the binary has turned WebKit's off.
|
||||
const { caps } = useNativeCapabilities()
|
||||
const pageRef = useRef<HTMLDivElement>(null)
|
||||
const edgeBack = isNativeIos() && caps.features.includes('edgeBack')
|
||||
|
||||
if (!isPhone) {
|
||||
return (
|
||||
@@ -59,7 +66,8 @@ export default function MobileShell({ isPhone, children }: MobileShellProps) {
|
||||
layer to be visible, body must not paint over it, which index.css handles
|
||||
for this breakpoint. */}
|
||||
<div aria-hidden className="pointer-events-none fixed inset-0 -z-10 bg-[color:var(--m-bg)] bg-[image:var(--m-scr)]" />
|
||||
<div className="flex-1">{children}</div>
|
||||
<div ref={pageRef} className="flex-1">{children}</div>
|
||||
<MEdgeBack target={pageRef} enabled={edgeBack} />
|
||||
{!inTripPlanner && <ErrorBoundary boundaryId="chrome:m-bottom-nav" fallback={null}><MBottomNav /></ErrorBoundary>}
|
||||
<ErrorBoundary boundaryId="chrome:m-toast" fallback={null}><MToastHost /></ErrorBoundary>
|
||||
<div id="m-sheet-root" />
|
||||
|
||||
@@ -120,7 +120,7 @@ export default function MBottomNav() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<nav className="fixed inset-x-4 z-40 flex h-[62px] items-center rounded-[31px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-3 shadow-[0_16px_44px_-14px_rgba(0,0,0,.35)] backdrop-blur-[30px] backdrop-saturate-[1.8] bottom-[calc(env(safe-area-inset-bottom,0px)+12px)]">
|
||||
<nav className="m-dock-vt fixed inset-x-4 z-40 flex h-[62px] items-center rounded-[31px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-3 shadow-[0_16px_44px_-14px_rgba(0,0,0,.35)] backdrop-blur-[30px] backdrop-saturate-[1.8] bottom-[calc(env(safe-area-inset-bottom,0px)+12px)]">
|
||||
<div className="flex min-w-0 flex-1 items-center justify-around">{left.map(renderItem)}</div>
|
||||
|
||||
{logoSlot ? (
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { tickHaptic } from '../../native/haptics';
|
||||
import type { Day } from '../../types';
|
||||
import MDayPager, { pagesFor } from './MDayPager';
|
||||
|
||||
vi.mock('../../native/haptics', () => ({ tickHaptic: vi.fn() }));
|
||||
|
||||
const DAYS = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }] as unknown as Day[];
|
||||
|
||||
function setup(selectedDayId: number | null, over: Partial<Parameters<typeof MDayPager>[0]> = {}) {
|
||||
const onSelectDay = vi.fn();
|
||||
const utils = render(
|
||||
<MDayPager
|
||||
days={DAYS}
|
||||
selectedDayId={selectedDayId}
|
||||
onSelectDay={onSelectDay}
|
||||
describeDay={(i, n) => `day ${i + 1} of ${n}`}
|
||||
renderDay={(dayId, active) => <p>{`page ${dayId ?? 'none'}${active ? ' live' : ''}`}</p>}
|
||||
{...over}
|
||||
/>
|
||||
);
|
||||
const strip = utils.container.querySelector('[data-day-pager]') as HTMLElement;
|
||||
return { ...utils, strip, onSelectDay };
|
||||
}
|
||||
|
||||
/** jsdom has no layout: say where each page sits, with `restingId` filling the strip. */
|
||||
function place(strip: HTMLElement, restingId: number | null, between = false) {
|
||||
strip.getBoundingClientRect = () => ({ left: 0 }) as DOMRect;
|
||||
for (const page of Array.from(strip.children) as HTMLElement[]) {
|
||||
const here = page.dataset.dayId === String(restingId);
|
||||
page.getBoundingClientRect = () => ({ left: here ? (between ? 37 : 0) : 400 }) as DOMRect;
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(tickHaptic).mockReset();
|
||||
});
|
||||
|
||||
describe('pagesFor', () => {
|
||||
it('windows the selection with its neighbours', () => {
|
||||
expect(pagesFor(DAYS, 2).map((p) => [p.dayId, p.active])).toEqual([
|
||||
[1, false],
|
||||
[2, true],
|
||||
[3, false],
|
||||
]);
|
||||
});
|
||||
|
||||
it('has no page beyond the first and last day', () => {
|
||||
expect(pagesFor(DAYS, 1).map((p) => p.dayId)).toEqual([1, 2]);
|
||||
expect(pagesFor(DAYS, 4).map((p) => p.dayId)).toEqual([3, 4]);
|
||||
});
|
||||
|
||||
it('keeps a page for a selection that points at no day, with the first day as the way back', () => {
|
||||
expect(pagesFor(DAYS, null).map((p) => [p.key, p.dayId, p.active])).toEqual([
|
||||
['none', null, true],
|
||||
['d1', 1, false],
|
||||
]);
|
||||
expect(pagesFor([], null).map((p) => p.key)).toEqual(['none']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MDayPager', () => {
|
||||
it('draws the current day live and its neighbours inert', () => {
|
||||
const { strip } = setup(2);
|
||||
|
||||
expect(screen.getByText('page 2 live')).toBeInTheDocument();
|
||||
const pages = Array.from(strip.children) as HTMLElement[];
|
||||
expect(pages.map((p) => p.hasAttribute('inert'))).toEqual([true, false, true]);
|
||||
expect(pages[0]).toHaveAttribute('aria-hidden', 'true');
|
||||
expect(pages[1]).not.toHaveAttribute('aria-hidden');
|
||||
});
|
||||
|
||||
it('selects the neighbour the strip comes to rest on, with a detent and an announcement', () => {
|
||||
const { strip, onSelectDay } = setup(2);
|
||||
|
||||
place(strip, 3);
|
||||
fireEvent(strip, new Event('scrollend'));
|
||||
|
||||
expect(onSelectDay).toHaveBeenCalledWith(3);
|
||||
expect(tickHaptic).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByRole('status')).toHaveTextContent('day 3 of 4');
|
||||
});
|
||||
|
||||
it('previews the day a swipe heads for while the strip still moves, and takes it back', () => {
|
||||
const onPreview = vi.fn();
|
||||
const { strip, onSelectDay } = setup(2, { onPreview });
|
||||
|
||||
place(strip, 3, true);
|
||||
fireEvent.scroll(strip);
|
||||
fireEvent.scroll(strip);
|
||||
expect(onPreview).toHaveBeenCalledTimes(1);
|
||||
expect(onPreview).toHaveBeenLastCalledWith(3);
|
||||
expect(onSelectDay).not.toHaveBeenCalled();
|
||||
|
||||
place(strip, 2, true);
|
||||
fireEvent.scroll(strip);
|
||||
expect(onPreview).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
|
||||
it('asks once, however many events report the same landing', () => {
|
||||
const { strip, onSelectDay } = setup(2);
|
||||
|
||||
place(strip, 1);
|
||||
fireEvent(strip, new Event('scrollend'));
|
||||
fireEvent(strip, new Event('scrollend'));
|
||||
|
||||
expect(onSelectDay).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does nothing when it comes back to the current day or is still between pages', () => {
|
||||
const { strip, onSelectDay } = setup(2);
|
||||
|
||||
place(strip, 2);
|
||||
fireEvent(strip, new Event('scrollend'));
|
||||
place(strip, 3, true);
|
||||
fireEvent(strip, new Event('scrollend'));
|
||||
|
||||
expect(onSelectDay).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('waits for the finger to lift where scrollend is missing', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { strip, onSelectDay } = setup(2);
|
||||
place(strip, 3);
|
||||
|
||||
fireEvent.touchStart(strip);
|
||||
fireEvent.scroll(strip);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
expect(onSelectDay).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.touchEnd(strip);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
expect(onSelectDay).toHaveBeenCalledWith(3);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('puts the strip back on the current page when the window moves', () => {
|
||||
const { strip, rerender, onSelectDay } = setup(2);
|
||||
// jsdom keeps no scroll offset, so give the strip one to move.
|
||||
let scrollLeft = 0;
|
||||
Object.defineProperty(strip, 'scrollLeft', {
|
||||
configurable: true,
|
||||
get: () => scrollLeft,
|
||||
set: (v) => {
|
||||
scrollLeft = v;
|
||||
},
|
||||
});
|
||||
// After the selection moves to day 3, its page sits one page width to the right.
|
||||
const realQuery = strip.querySelector.bind(strip);
|
||||
vi.spyOn(strip, 'querySelector').mockImplementation((sel: string) => {
|
||||
const el = realQuery(sel) as HTMLElement | null;
|
||||
if (el && sel === '[data-page-active]') el.getBoundingClientRect = () => ({ left: 390 }) as DOMRect;
|
||||
return el;
|
||||
});
|
||||
strip.getBoundingClientRect = () => ({ left: 0 }) as DOMRect;
|
||||
|
||||
rerender(
|
||||
<MDayPager
|
||||
days={DAYS}
|
||||
selectedDayId={3}
|
||||
onSelectDay={onSelectDay}
|
||||
describeDay={() => ''}
|
||||
renderDay={(dayId) => <p>{`page ${dayId}`}</p>}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(strip.scrollLeft).toBe(390);
|
||||
});
|
||||
|
||||
it('holds still while locked', () => {
|
||||
const { strip } = setup(2, { locked: true });
|
||||
expect(strip).toHaveClass('overflow-x-hidden');
|
||||
expect(strip).not.toHaveClass('overflow-x-auto');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { tickHaptic } from '../../native/haptics';
|
||||
import type { Day } from '../../types';
|
||||
|
||||
/**
|
||||
* Pages through the trip's days the way a native pager does: the previous,
|
||||
* current and next day sit side by side in a horizontal scroll-snap strip, so
|
||||
* the finger drags real paper, the neighbour slides in with it, and the
|
||||
* browser's compositor owns the gesture. Momentum, rubber-banding at the first
|
||||
* and last day, axis locking against the vertical scroll and the swallowed tap
|
||||
* after a swipe are all the platform's own, in the PWA and the app alike.
|
||||
*
|
||||
* The day only changes once the strip has come to rest on a neighbour. Then
|
||||
* the selection moves, the window of three shifts by one and the strip is put
|
||||
* back on the centre page before the next paint, so nothing visibly jumps.
|
||||
* Keys follow the day ids, so the page that slid in keeps its state (scroll
|
||||
* position, fetched route and weather) as it becomes the current one.
|
||||
*
|
||||
* RTL needs nothing of its own: under dir="rtl" the strip lays out right to
|
||||
* left, which puts the next day on the left, and every offset here is read off
|
||||
* bounding rects rather than scrollLeft, whose sign flips in RTL.
|
||||
*/
|
||||
|
||||
/** Fallback settle delay where `scrollend` is missing (Safari before 26). */
|
||||
const SETTLE_MS = 90;
|
||||
/** How close to a page edge counts as resting on it. */
|
||||
const REST_PX = 2;
|
||||
|
||||
/** Anything with an id pages: trip days, a leave year's months, the plan's years. */
|
||||
export type PagerItem = Pick<Day, 'id'>;
|
||||
|
||||
export interface MDayPagerProps {
|
||||
days: PagerItem[];
|
||||
selectedDayId: number | null;
|
||||
onSelectDay: (dayId: number) => void;
|
||||
/** Draws one day. Pages other than the current one are inert and hidden
|
||||
* from assistive tech, so they never take focus or a tap. */
|
||||
renderDay: (dayId: number | null, active: boolean) => ReactNode;
|
||||
/** Holds the strip still: a reorder drag or an open menu owns the finger. */
|
||||
locked?: boolean;
|
||||
/** Live-region text after a swipe lands on a day. */
|
||||
describeDay: (index: number, total: number) => string;
|
||||
/**
|
||||
* In the page's flow instead of filling a positioned parent: the strip takes
|
||||
* the height of its tallest page, for content that sits in a scrolling column
|
||||
* (Vacay's month editor and year grid) rather than over the whole screen.
|
||||
*/
|
||||
inFlow?: boolean;
|
||||
/**
|
||||
* The day a swipe is heading for, as soon as its page is the nearer one, and
|
||||
* null when the swipe falls back: lets a day rail follow the finger before
|
||||
* the snap lands and onSelectDay fires.
|
||||
*/
|
||||
onPreview?: (dayId: number | null) => void;
|
||||
}
|
||||
|
||||
interface Page {
|
||||
key: string;
|
||||
dayId: number | null;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/** The window of pages around the selection. A selection that points at no
|
||||
* day (the whole-trip view, or a day deleted remotely) still gets its page,
|
||||
* with the first day next to it as a way back. */
|
||||
export function pagesFor(days: PagerItem[], selectedDayId: number | null): Page[] {
|
||||
const i = days.findIndex((d) => d.id === selectedDayId);
|
||||
if (i < 0) {
|
||||
const first = days[0];
|
||||
return [
|
||||
{ key: 'none', dayId: selectedDayId, active: true },
|
||||
...(first ? [{ key: `d${first.id}`, dayId: first.id, active: false }] : []),
|
||||
];
|
||||
}
|
||||
const pages: Page[] = [];
|
||||
const prev = days[i - 1];
|
||||
const next = days[i + 1];
|
||||
if (prev) pages.push({ key: `d${prev.id}`, dayId: prev.id, active: false });
|
||||
pages.push({ key: `d${days[i].id}`, dayId: days[i].id, active: true });
|
||||
if (next) pages.push({ key: `d${next.id}`, dayId: next.id, active: false });
|
||||
return pages;
|
||||
}
|
||||
|
||||
export default function MDayPager({
|
||||
days,
|
||||
selectedDayId,
|
||||
onSelectDay,
|
||||
renderDay,
|
||||
locked = false,
|
||||
describeDay,
|
||||
inFlow = false,
|
||||
onPreview,
|
||||
}: MDayPagerProps) {
|
||||
const stripRef = useRef<HTMLDivElement>(null);
|
||||
const [announcement, setAnnouncement] = useState('');
|
||||
const pages = pagesFor(days, selectedDayId);
|
||||
const windowKey = pages.map((p) => p.key).join(',');
|
||||
|
||||
// The handlers below are attached once, so they read the props through here.
|
||||
const latest = useRef({ days, onSelectDay, describeDay, selectedDayId, onPreview });
|
||||
latest.current = { days, onSelectDay, describeDay, selectedDayId, onPreview };
|
||||
/** The day a landed swipe asked for, until the selection catches up. Stops a
|
||||
* late scroll event from selecting it twice. */
|
||||
const requested = useRef<number | null>(null);
|
||||
|
||||
/** Puts the strip back on the current page, instantly. */
|
||||
const recentre = useCallback(() => {
|
||||
const strip = stripRef.current;
|
||||
const current = strip?.querySelector<HTMLElement>('[data-page-active]');
|
||||
if (!strip || !current) return;
|
||||
const offset = current.getBoundingClientRect().left - strip.getBoundingClientRect().left;
|
||||
if (Math.abs(offset) > 0.5) strip.scrollLeft += offset;
|
||||
}, []);
|
||||
|
||||
// Before paint whenever the window moves: a landed swipe or a chip tap.
|
||||
useLayoutEffect(() => {
|
||||
requested.current = null;
|
||||
recentre();
|
||||
}, [windowKey, recentre]);
|
||||
|
||||
// Rotation and split view change the page width; keep the current page whole.
|
||||
useEffect(() => {
|
||||
const strip = stripRef.current;
|
||||
if (!strip || typeof ResizeObserver === 'undefined') return;
|
||||
const observer = new ResizeObserver(() => recentre());
|
||||
observer.observe(strip);
|
||||
return () => observer.disconnect();
|
||||
}, [recentre]);
|
||||
|
||||
useEffect(() => {
|
||||
const strip = stripRef.current;
|
||||
if (!strip) return;
|
||||
let timer = 0;
|
||||
let touching = false;
|
||||
let previewed: number | null = null;
|
||||
|
||||
// The nearer page while the strip moves: a page past the middle is where the
|
||||
// swipe will land unless the finger turns back.
|
||||
const preview = () => {
|
||||
const notify = latest.current.onPreview;
|
||||
if (!notify) return;
|
||||
const left = strip.getBoundingClientRect().left;
|
||||
let nearest: HTMLElement | null = null;
|
||||
let distance = Infinity;
|
||||
for (const child of Array.from(strip.children) as HTMLElement[]) {
|
||||
const d = Math.abs(child.getBoundingClientRect().left - left);
|
||||
if (d < distance) {
|
||||
distance = d;
|
||||
nearest = child;
|
||||
}
|
||||
}
|
||||
if (!nearest) return;
|
||||
const raw = nearest.hasAttribute('data-page-active') ? '' : nearest.dataset.dayId;
|
||||
const dayId = raw ? Number(raw) : null;
|
||||
const next = dayId != null && Number.isFinite(dayId) ? dayId : null;
|
||||
if (next === previewed) return;
|
||||
previewed = next;
|
||||
notify(next);
|
||||
};
|
||||
|
||||
const settle = () => {
|
||||
if (touching) return;
|
||||
const left = strip.getBoundingClientRect().left;
|
||||
let resting: HTMLElement | null = null;
|
||||
for (const child of Array.from(strip.children) as HTMLElement[]) {
|
||||
if (Math.abs(child.getBoundingClientRect().left - left) <= REST_PX) resting = child;
|
||||
}
|
||||
// Still between pages: the snap has not finished, another event follows.
|
||||
if (!resting || resting.hasAttribute('data-page-active')) return;
|
||||
const raw = resting.dataset.dayId;
|
||||
const dayId = raw ? Number(raw) : NaN;
|
||||
if (!Number.isFinite(dayId) || requested.current === dayId) return;
|
||||
requested.current = dayId;
|
||||
previewed = null;
|
||||
const s = latest.current;
|
||||
tickHaptic();
|
||||
s.onSelectDay(dayId);
|
||||
const index = s.days.findIndex((d) => d.id === dayId);
|
||||
if (index >= 0) setAnnouncement(s.describeDay(index, s.days.length));
|
||||
};
|
||||
const later = () => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(settle, SETTLE_MS);
|
||||
};
|
||||
const onScrollEnd = () => {
|
||||
window.clearTimeout(timer);
|
||||
settle();
|
||||
};
|
||||
const onTouchStart = () => {
|
||||
touching = true;
|
||||
};
|
||||
const onTouchEnd = () => {
|
||||
touching = false;
|
||||
later();
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
preview();
|
||||
later();
|
||||
};
|
||||
strip.addEventListener('scroll', onScroll, { passive: true });
|
||||
strip.addEventListener('scrollend', onScrollEnd);
|
||||
strip.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||
strip.addEventListener('touchend', onTouchEnd, { passive: true });
|
||||
strip.addEventListener('touchcancel', onTouchEnd, { passive: true });
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
strip.removeEventListener('scroll', onScroll);
|
||||
strip.removeEventListener('scrollend', onScrollEnd);
|
||||
strip.removeEventListener('touchstart', onTouchStart);
|
||||
strip.removeEventListener('touchend', onTouchEnd);
|
||||
strip.removeEventListener('touchcancel', onTouchEnd);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Swipe-landed days only. A chip tap already speaks its own label plus
|
||||
the aria-current flip, so announcing there would say the day twice. */}
|
||||
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">
|
||||
{announcement}
|
||||
</span>
|
||||
<div
|
||||
ref={stripRef}
|
||||
data-day-pager=""
|
||||
className={`${inFlow ? 'relative items-start' : 'absolute inset-0'} flex snap-x snap-mandatory overflow-y-hidden overscroll-x-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden ${locked ? 'overflow-x-hidden' : 'overflow-x-auto'}`}
|
||||
>
|
||||
{pages.map((page) => (
|
||||
<div
|
||||
key={page.key}
|
||||
data-day-id={page.dayId ?? ''}
|
||||
data-page-active={page.active ? '' : undefined}
|
||||
inert={!page.active}
|
||||
aria-hidden={page.active ? undefined : true}
|
||||
className={`relative w-full flex-none snap-center snap-always ${inFlow ? '' : 'h-full'}`}
|
||||
>
|
||||
{renderDay(page.dayId, page.active)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { useRef } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { tickHaptic } from '../../native/haptics';
|
||||
import { closeTopOverlay, hasOpenOverlay } from '../overlayStack';
|
||||
import MEdgeBack from './MEdgeBack';
|
||||
|
||||
vi.mock('../overlayStack', () => ({ closeTopOverlay: vi.fn(), hasOpenOverlay: vi.fn(() => false) }));
|
||||
vi.mock('../../native/haptics', () => ({ tickHaptic: vi.fn() }));
|
||||
|
||||
function Host({ enabled = true }: { enabled?: boolean }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
<>
|
||||
<div ref={ref} data-testid="page" />
|
||||
<MEdgeBack target={ref} enabled={enabled} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function touch(type: string, x: number, y = 300, t = 0) {
|
||||
const e = new Event(type, { bubbles: true, cancelable: true }) as Event & Record<string, unknown>;
|
||||
Object.defineProperty(e, 'touches', { value: type === 'touchend' ? [] : [{ clientX: x, clientY: y }] });
|
||||
Object.defineProperty(e, 'changedTouches', { value: [{ clientX: x, clientY: y }] });
|
||||
Object.defineProperty(e, 'timeStamp', { value: t });
|
||||
document.dispatchEvent(e);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(hasOpenOverlay).mockReturnValue(false);
|
||||
window.history.replaceState({ idx: 2 }, '');
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 400 });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('MEdgeBack', () => {
|
||||
it('drags the page with a finger from the left edge, and goes back past a third', () => {
|
||||
const back = vi.spyOn(window.history, 'back').mockImplementation(() => {});
|
||||
const { getByTestId } = render(<Host />);
|
||||
touch('touchstart', 5, 300, 0);
|
||||
touch('touchmove', 40, 302, 50);
|
||||
touch('touchmove', 200, 304, 400);
|
||||
expect(getByTestId('page').style.transform).toBe('translateX(195px)');
|
||||
touch('touchend', 200, 304, 900);
|
||||
vi.advanceTimersByTime(250);
|
||||
expect(back).toHaveBeenCalledTimes(1);
|
||||
expect(tickHaptic).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('springs back on a short, slow drag', () => {
|
||||
const back = vi.spyOn(window.history, 'back').mockImplementation(() => {});
|
||||
const { getByTestId } = render(<Host />);
|
||||
touch('touchstart', 5, 300, 0);
|
||||
touch('touchmove', 30, 300, 200);
|
||||
touch('touchmove', 60, 300, 800);
|
||||
touch('touchend', 60, 300, 1600);
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(back).not.toHaveBeenCalled();
|
||||
expect(getByTestId('page').style.transform).toBe('');
|
||||
});
|
||||
|
||||
it('closes an open sheet first and leaves the page where it is', () => {
|
||||
vi.mocked(hasOpenOverlay).mockReturnValue(true);
|
||||
const back = vi.spyOn(window.history, 'back').mockImplementation(() => {});
|
||||
const { getByTestId } = render(<Host />);
|
||||
touch('touchstart', 5, 300, 0);
|
||||
touch('touchmove', 200, 300, 100);
|
||||
expect(getByTestId('page').style.transform).toBe('');
|
||||
touch('touchend', 200, 300, 200);
|
||||
expect(closeTopOverlay).toHaveBeenCalledTimes(1);
|
||||
expect(back).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores a touch away from the edge, a vertical scroll, the first page and a binary without the feature', () => {
|
||||
const back = vi.spyOn(window.history, 'back').mockImplementation(() => {});
|
||||
const { unmount } = render(<Host />);
|
||||
touch('touchstart', 120);
|
||||
touch('touchmove', 300);
|
||||
touch('touchend', 300, 300, 100);
|
||||
touch('touchstart', 5, 300);
|
||||
touch('touchmove', 8, 400);
|
||||
touch('touchend', 8, 400, 100);
|
||||
window.history.replaceState({ idx: 0 }, '');
|
||||
touch('touchstart', 5);
|
||||
touch('touchmove', 300);
|
||||
touch('touchend', 300, 300, 100);
|
||||
unmount();
|
||||
window.history.replaceState({ idx: 2 }, '');
|
||||
render(<Host enabled={false} />);
|
||||
touch('touchstart', 5);
|
||||
touch('touchmove', 300);
|
||||
touch('touchend', 300, 300, 100);
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(back).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { RefObject } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { tickHaptic } from '../../native/haptics';
|
||||
import { closeTopOverlay, hasOpenOverlay } from '../overlayStack';
|
||||
import { skipNextRouteTransition } from '../routeTransition';
|
||||
|
||||
/**
|
||||
* The swipe from the left edge that goes back, the way every iOS app has it.
|
||||
*
|
||||
* WebKit's own version slides away a screenshot of the whole page, closes no
|
||||
* sheet and fights every gesture near the edge (the day pager, swipe rows), so
|
||||
* the app turns it off (TrekBridgeViewController) and this takes over: the
|
||||
* page follows the finger, and a release past a third of the screen or a flick
|
||||
* slides it off and goes back. An open sheet closes first instead, the same
|
||||
* order Android's back gesture follows. Mounted only where the binary reports
|
||||
* the `edgeBack` feature, so an older binary keeps WebKit's.
|
||||
*/
|
||||
|
||||
/** A touch has to start this close to the left edge. */
|
||||
const EDGE_PX = 22;
|
||||
/** Sideways must clearly beat vertical before the gesture is claimed. */
|
||||
const CLAIM_PX = 10;
|
||||
const AXIS_BIAS = 1.5;
|
||||
const COMMIT_SHARE = 0.33;
|
||||
const FLICK_V = 0.5;
|
||||
|
||||
/** react-router keeps its position in the session history on history.state. */
|
||||
function canGoBack(): boolean {
|
||||
const idx = (window.history.state as { idx?: number } | null)?.idx;
|
||||
return typeof idx === 'number' && idx > 0;
|
||||
}
|
||||
|
||||
export default function MEdgeBack({ target, enabled }: { target: RefObject<HTMLElement | null>; enabled: boolean }) {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let start: { x: number; y: number; t: number } | null = null;
|
||||
let claimed = false;
|
||||
let overlay = false;
|
||||
let last = { x: 0, t: 0 };
|
||||
|
||||
const move = (x: number, transition: string) => {
|
||||
const el = target.current;
|
||||
if (!el) return;
|
||||
el.style.transition = transition;
|
||||
el.style.transform = x ? `translateX(${x}px)` : '';
|
||||
el.style.boxShadow = x ? '-16px 0 40px -12px rgba(0,0,0,.35)' : '';
|
||||
};
|
||||
|
||||
const onStart = (e: TouchEvent) => {
|
||||
claimed = false;
|
||||
start = null;
|
||||
if (e.touches.length !== 1) return;
|
||||
const t = e.touches[0];
|
||||
if (t.clientX > EDGE_PX) return;
|
||||
overlay = hasOpenOverlay();
|
||||
if (!overlay && !canGoBack()) return;
|
||||
start = { x: t.clientX, y: t.clientY, t: e.timeStamp };
|
||||
last = { x: t.clientX, t: e.timeStamp };
|
||||
};
|
||||
|
||||
const onMove = (e: TouchEvent) => {
|
||||
if (!start || e.touches.length !== 1) return;
|
||||
const t = e.touches[0];
|
||||
const dx = t.clientX - start.x;
|
||||
const dy = t.clientY - start.y;
|
||||
if (!claimed) {
|
||||
if (Math.abs(dy) > CLAIM_PX && Math.abs(dy) > Math.abs(dx)) {
|
||||
start = null;
|
||||
return;
|
||||
}
|
||||
if (dx < CLAIM_PX || dx < Math.abs(dy) * AXIS_BIAS) return;
|
||||
claimed = true;
|
||||
}
|
||||
if (e.cancelable) e.preventDefault();
|
||||
last = { x: t.clientX, t: e.timeStamp };
|
||||
// An open sheet closes on release; the page itself stays put under it.
|
||||
if (!overlay) move(Math.max(0, dx), 'none');
|
||||
};
|
||||
|
||||
const onEnd = (e: TouchEvent) => {
|
||||
const s = start;
|
||||
start = null;
|
||||
if (!s || !claimed) return;
|
||||
const x = e.changedTouches[0]?.clientX ?? last.x;
|
||||
const dx = Math.max(0, x - s.x);
|
||||
const v = (x - last.x) / Math.max(1, e.timeStamp - last.t) || dx / Math.max(1, e.timeStamp - s.t);
|
||||
const width = window.innerWidth;
|
||||
const commit = dx > width * COMMIT_SHARE || v > FLICK_V;
|
||||
if (!commit) {
|
||||
move(0, 'transform 260ms var(--ease-drawer), box-shadow 260ms');
|
||||
return;
|
||||
}
|
||||
tickHaptic();
|
||||
if (overlay) {
|
||||
closeTopOverlay();
|
||||
return;
|
||||
}
|
||||
move(width, 'transform 200ms cubic-bezier(0.2, 0.8, 0.2, 1)');
|
||||
window.setTimeout(() => {
|
||||
skipNextRouteTransition();
|
||||
window.history.back();
|
||||
// The page that comes back takes the place without a slide of its own.
|
||||
requestAnimationFrame(() => move(0, 'none'));
|
||||
}, 190);
|
||||
};
|
||||
|
||||
document.addEventListener('touchstart', onStart, { passive: true });
|
||||
document.addEventListener('touchmove', onMove, { passive: false });
|
||||
document.addEventListener('touchend', onEnd, { passive: true });
|
||||
document.addEventListener('touchcancel', onEnd, { passive: true });
|
||||
return () => {
|
||||
document.removeEventListener('touchstart', onStart);
|
||||
document.removeEventListener('touchmove', onMove);
|
||||
document.removeEventListener('touchend', onEnd);
|
||||
document.removeEventListener('touchcancel', onEnd);
|
||||
move(0, 'none');
|
||||
};
|
||||
}, [enabled, target]);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Check, Copy, Share as ShareIcon } from 'lucide-react';
|
||||
import { canShareLink, shareLink } from '../../native/shareLink';
|
||||
|
||||
/** The round icon buttons next to the link: copy and share, the same size. */
|
||||
const ACTION = 'flex h-[34px] w-[34px] flex-none items-center justify-center rounded-full bg-m-act text-m-actfg';
|
||||
|
||||
/**
|
||||
* A link to hand on, read-only, with copy and (where the system has a share
|
||||
* sheet) share next to it: the trip's share and invite links and a journey's
|
||||
* public link all look the same.
|
||||
*/
|
||||
export default function MLinkField({
|
||||
url,
|
||||
copied,
|
||||
onCopy,
|
||||
copyLabel,
|
||||
copiedLabel,
|
||||
shareLabel,
|
||||
}: {
|
||||
url: string;
|
||||
copied: boolean;
|
||||
onCopy: () => void;
|
||||
copyLabel: string;
|
||||
copiedLabel: string;
|
||||
shareLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] py-1 pe-1 ps-[14px]">
|
||||
<input
|
||||
readOnly
|
||||
value={url}
|
||||
aria-label={url}
|
||||
className="min-w-0 flex-1 border-none bg-transparent font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] text-m-muted outline-none"
|
||||
/>
|
||||
<button type="button" aria-label={copied ? copiedLabel : copyLabel} onClick={onCopy} className={ACTION}>
|
||||
{copied ? <Check size={15} strokeWidth={2.4} /> : <Copy size={15} strokeWidth={2.2} />}
|
||||
</button>
|
||||
{canShareLink() && (
|
||||
<button type="button" aria-label={shareLabel} onClick={() => void shareLink(url)} className={ACTION}>
|
||||
<ShareIcon size={15} strokeWidth={2.2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { PULL_THRESHOLD, type PullState } from '../usePullToRefresh';
|
||||
|
||||
/**
|
||||
* The round glass badge under the top bar that a pull-to-refresh reveals: it
|
||||
* fades in and turns with the pull, and spins while the page reloads.
|
||||
*/
|
||||
export default function MPullIndicator({ state, label }: { state: PullState; label: string }) {
|
||||
const progress = Math.min(1, state.distance / PULL_THRESHOLD);
|
||||
const visible = state.refreshing || state.distance > 4;
|
||||
return (
|
||||
<div
|
||||
aria-hidden={!state.refreshing}
|
||||
role={state.refreshing ? 'status' : undefined}
|
||||
aria-label={state.refreshing ? label : undefined}
|
||||
className="pointer-events-none fixed inset-x-0 z-30 flex justify-center transition-opacity duration-200"
|
||||
style={{ top: 'calc(var(--m-safe-top, 12px) + 70px)', opacity: visible ? Math.max(0.35, progress) : 0 }}
|
||||
>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] text-m-ink shadow-sm backdrop-blur-xl">
|
||||
<RefreshCw
|
||||
size={16}
|
||||
strokeWidth={2.2}
|
||||
className={state.refreshing ? 'animate-spin' : undefined}
|
||||
style={state.refreshing ? undefined : { transform: `rotate(${progress * 270}deg)` }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
export default function MRouteFallback() {
|
||||
return (
|
||||
<div className="m-root flex h-dvh flex-col gap-3 px-4 pt-14 bg-[color:var(--m-bg)]">
|
||||
<div data-route-fallback className="m-root flex h-dvh flex-col gap-3 px-4 pt-14 bg-[color:var(--m-bg)]">
|
||||
<div className="h-7 w-40 animate-pulse rounded-lg bg-m-card" />
|
||||
<div className="h-28 animate-pulse rounded-xl bg-m-card" />
|
||||
<div className="h-28 animate-pulse rounded-xl bg-m-card" />
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { ReactNode, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { ReactNode, useRef } from 'react'
|
||||
import { useActivePill } from './useActivePill'
|
||||
import { selectionHaptic } from '../../native/haptics'
|
||||
|
||||
export interface MSegmentedOption<T extends string = string> {
|
||||
value: T
|
||||
@@ -26,20 +28,7 @@ export default function MSegmented<T extends string = string>({
|
||||
className = '',
|
||||
}: MSegmentedProps<T>) {
|
||||
const trackRef = useRef<HTMLDivElement>(null)
|
||||
const [indicator, setIndicator] = useState<{ left: number; width: number } | null>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const track = trackRef.current
|
||||
if (!track) return
|
||||
const measure = () => {
|
||||
const active = track.querySelector<HTMLElement>('[data-active="true"]')
|
||||
if (active) setIndicator({ left: active.offsetLeft, width: active.offsetWidth })
|
||||
}
|
||||
measure()
|
||||
const ro = new ResizeObserver(measure)
|
||||
ro.observe(track)
|
||||
return () => ro.disconnect()
|
||||
}, [value, options.length])
|
||||
const pill = useActivePill(trackRef, '[data-active="true"]', [value, options.length])
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -47,12 +36,7 @@ export default function MSegmented<T extends string = string>({
|
||||
role="tablist"
|
||||
className={`relative flex rounded-full bg-[color:var(--m-ic)] p-[3px] ${className}`}
|
||||
>
|
||||
{indicator && (
|
||||
<div
|
||||
className="absolute top-[3px] bottom-[3px] rounded-full bg-m-act transition-[left,width] duration-[280ms] ease-[var(--ease-drawer)]"
|
||||
style={{ left: indicator.left, width: indicator.width }}
|
||||
/>
|
||||
)}
|
||||
{pill && <div aria-hidden="true" className="absolute bottom-[3px] left-0 top-[3px] rounded-full bg-m-act" style={pill} />}
|
||||
{options.map((opt) => {
|
||||
const active = opt.value === value
|
||||
return (
|
||||
@@ -62,10 +46,13 @@ export default function MSegmented<T extends string = string>({
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
data-active={active}
|
||||
onClick={() => onChange(opt.value)}
|
||||
onClick={() => {
|
||||
if (!active) selectionHaptic()
|
||||
onChange(opt.value)
|
||||
}}
|
||||
className={`relative z-[1] rounded-full py-[7px] text-center ${
|
||||
variant === 'intrinsic'
|
||||
? 'whitespace-nowrap px-[13px] text-[0.75rem]'
|
||||
? 'whitespace-nowrap px-[11px] text-[0.75rem]'
|
||||
: 'flex-1 text-[0.71875rem]'
|
||||
} ${active ? 'font-semibold text-m-actfg' : 'font-medium text-m-ink'}`}
|
||||
>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user