mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-10 22:31:01 +00:00
Compare commits
132 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 792e55b2ca | |||
| 5462bce9c3 | |||
| ede54d10cd | |||
| e7a99db03b | |||
| e5a119b6bd | |||
| 315db3fa5a | |||
| 19ef38a377 | |||
| 7797a9a562 | |||
| 55e4bfa11e | |||
| ad0fa41bb5 | |||
| 7c91a609da | |||
| 545eb650ca | |||
| 43f86f4559 | |||
| 25f2cd8192 | |||
| 7ae9198a43 | |||
| 8f3a8bd670 | |||
| e0c3642874 | |||
| 25091dbd60 | |||
| fe7847fc08 | |||
| 77b9252577 | |||
| 3364c0d131 | |||
| deabaf7398 | |||
| 9b780d6b7e | |||
| 93727084ec | |||
| ed178f3b5e | |||
| f46f6f38fc | |||
| ae082c1fd0 | |||
| 6b8eb939c3 | |||
| 89f7bd2e2f | |||
| 2fe487154f | |||
| 71d014af95 | |||
| b1037dec62 | |||
| 3466556849 | |||
| b7951baf23 | |||
| 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 | |||
| 4c0583845a | |||
| d7c4324afc | |||
| dbcfbd875e | |||
| d2002cc913 | |||
| 4bc764eb6a | |||
| 1f89a13f92 | |||
| 93a23f0343 | |||
| ce69ae99a1 | |||
| c7d5fc12c6 | |||
| b9c53d692b | |||
| 13376d560f | |||
| 3a8191fc07 | |||
| c0f7f81e23 | |||
| 7743e4da16 | |||
| 887f5d8c84 | |||
| 2ec1199490 | |||
| 9dd58fe32f | |||
| 402a02a302 | |||
| 7b5a05afee | |||
| c3357ebea4 | |||
| 91f6430762 | |||
| 6ee540d5e4 | |||
| 99cdcfcd09 | |||
| fd11944ed6 | |||
| 82c6a3cd5f | |||
| fbafa270c4 | |||
| 1c54d7d6c1 | |||
| d2c7d83256 |
@@ -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,44 @@ jobs:
|
||||
ASC_KEY_P8_B64: ${{ secrets.ASC_KEY_P8_B64 }}
|
||||
run: printf '%s' "$ASC_KEY_P8_B64" | base64 -d > "$RUNNER_TEMP/AuthKey.p8"
|
||||
|
||||
- name: 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.
|
||||
# The tag's message, past its subject line, is what testers read under
|
||||
# "What to Test": one short line per change worth trying.
|
||||
- name: TestFlight notes from the tag
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
run: |
|
||||
git fetch --force --depth=1 origin "+${GITHUB_REF}:${GITHUB_REF}"
|
||||
{
|
||||
echo 'TESTFLIGHT_NOTES<<TREK_NOTES_END'
|
||||
# A signed tag carries its signature at the end of the message.
|
||||
git for-each-ref "$GITHUB_REF" --format='%(contents:body)' | sed '/-----BEGIN .*SIGNATURE-----/,$d'
|
||||
echo 'TREK_NOTES_END'
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build and upload to TestFlight
|
||||
working-directory: client
|
||||
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 client/ios/App/TREKWidgets/*.lproj/Localizable.strings
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -130,3 +130,32 @@ jobs:
|
||||
-sdk iphonesimulator \
|
||||
-destination 'generic/platform=iOS Simulator' \
|
||||
CODE_SIGNING_ALLOWED=NO
|
||||
|
||||
# The widgets drawn to PNGs (client/ios/WidgetPreviews): every size, light and
|
||||
# dark, for a trip ahead, a trip under way and none, plus a home screen and
|
||||
# a lock screen of them. Nobody has to install a build to see a widget change.
|
||||
widget-previews:
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Select Xcode 26
|
||||
run: |
|
||||
XCODE=$(ls -d /Applications/Xcode_26*.app | sort -V | tail -1)
|
||||
test -n "$XCODE"
|
||||
sudo xcode-select -s "$XCODE"
|
||||
|
||||
- name: Render the widgets
|
||||
working-directory: client/ios/WidgetPreviews
|
||||
run: |
|
||||
mkdir -p Sources/WidgetPreviews/Widgets
|
||||
cp ../App/TREKWidgets/WidgetSnapshot.swift ../App/TREKWidgets/TrekStyle.swift ../App/TREKWidgets/TrekFamily.swift ../App/TREKWidgets/WorldGrid.swift \
|
||||
../App/TREKWidgets/*Content.swift Sources/WidgetPreviews/Widgets/
|
||||
swift run -c release WidgetPreviews out "$PWD/../App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png"
|
||||
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: widget-previews
|
||||
path: client/ios/WidgetPreviews/out
|
||||
retention-days: 14
|
||||
|
||||
+1
-1
@@ -56,5 +56,5 @@ See `values.yaml` for more options.
|
||||
- `COOKIE_SECURE` is auto-derived (on when `NODE_ENV=production` or `FORCE_HTTPS=true`). Set `env.COOKIE_SECURE: "false"` only during local testing without TLS. **Not recommended for production.**
|
||||
- Web Push needs no chart settings: TREK generates its key pair on first start and keeps it in the database on the data PVC. To bring your own pair, set `env.VAPID_PUBLIC_KEY` and `secretEnv.VAPID_PRIVATE_KEY` together (and optionally `env.VAPID_SUBJECT`); changing the pair later means every device has to turn push on again.
|
||||
- Set `env.OIDC_DISCOVERY_URL` to override the auto-constructed OIDC discovery endpoint. Required for providers (e.g. Authentik) that expose it at a non-standard path.
|
||||
- `podSecurityContext` defaults to `fsGroup: 1000` with `fsGroupChangePolicy: OnRootMismatch`, and `securityContext` (the container's) is empty. The container then starts as root, hands any file under `/app/data` and `/app/uploads` that is not owned by `node` yet to `node`, and drops to `node`. To run as `node` from the start, for example under the `restricted` Pod Security Standard, set `podSecurityContext.runAsUser: 1000` and `runAsGroup: 1000`; the image then skips that step and the privilege drop. See the example in `values.yaml`.
|
||||
- `podSecurityContext` defaults to `fsGroup: 1000` with `fsGroupChangePolicy: OnRootMismatch`, and `securityContext` (the container's) is empty. The container then starts as root, hands `/app/data` and `/app/uploads` to `node` (uid 1000) if the directory or anything directly inside it is not owned by `node` yet, and drops to `node`. Deeper files are not checked. Only the owning user is changed (plus a `root` group), so a different `fsGroup` stays in place. A volume that refuses `chown` (NFS with `root_squash`) logs a warning instead; set `runAsUser: 1000` then. To run as `node` from the start, for example under the `restricted` Pod Security Standard, set `podSecurityContext.runAsUser: 1000` and `runAsGroup: 1000`; the image then skips that step and the privilege drop. See the example in `values.yaml`.
|
||||
- Use `extraVolumes` and `extraVolumeMounts` to add your own volumes to the pod. To give the booking import more KItinerary extractors, mount a ConfigMap at `/usr/local/share/kitinerary/extractors`, with one `.json` and one `.js` key for each extractor; see the example in `values.yaml`.
|
||||
|
||||
@@ -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,104 @@
|
||||
import { expect, test, type Locator } from '@playwright/test';
|
||||
|
||||
// #2981: labels, category and status took the whole row, so a place with two or
|
||||
// three long labels showed one letter of its name or none, and the status pill
|
||||
// ran out of the card. Measured in a real browser because the layout is the bug.
|
||||
const PLACES = [
|
||||
{ name: 'Oceanario de Lisboa', labels: ['Core shortlist', 'Heritage specialist', 'Pair with another stop'] },
|
||||
{ name: 'Cove of Ursa', labels: ['Heritage specialist', 'Pair with another stop'] },
|
||||
{ name: 'A Vida Portuguesa', labels: ['Flagship', 'Market', 'Core shortlist', 'Pair with another stop'] },
|
||||
{ name: 'Torre de Belem', labels: [] as string[] },
|
||||
];
|
||||
|
||||
async function expectInside(row: Locator) {
|
||||
const escapes = await row.evaluate((el) => {
|
||||
const box = el.getBoundingClientRect();
|
||||
return Array.from(el.querySelectorAll('*')).filter((child) => {
|
||||
// The strip's hidden measuring copy is meant to run past the bar.
|
||||
if (child.closest('[aria-hidden="true"]')) return false;
|
||||
const r = child.getBoundingClientRect();
|
||||
return r.width > 0 && (r.left < box.left - 0.5 || r.right > box.right + 0.5);
|
||||
}).length;
|
||||
});
|
||||
expect(escapes).toBe(0);
|
||||
}
|
||||
|
||||
for (const viewport of [
|
||||
{ width: 1920, height: 1000 },
|
||||
{ width: 1400, height: 900 },
|
||||
{ width: 1024, height: 800 },
|
||||
]) {
|
||||
test(`place names stay readable next to long labels at ${viewport.width}px`, async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
await page.setViewportSize(viewport);
|
||||
const addonsResponse = await page.request.get('/api/admin/addons');
|
||||
expect(addonsResponse.ok()).toBeTruthy();
|
||||
const { addons } = await addonsResponse.json();
|
||||
const original = addons.find((addon: { id: string }) => addon.id === 'collections');
|
||||
expect(original).toBeDefined();
|
||||
let collectionId: number | undefined;
|
||||
|
||||
try {
|
||||
expect((await page.request.put('/api/admin/addons/collections', { data: { enabled: true } })).ok()).toBeTruthy();
|
||||
const created = await page.request.post('/api/addons/collections', { data: { name: `Rows ${viewport.width}` } });
|
||||
expect(created.ok()).toBeTruthy();
|
||||
const body = await created.json();
|
||||
collectionId = (body.collection ?? body).id as number;
|
||||
|
||||
const labelIds = new Map<string, number>();
|
||||
for (const name of new Set(PLACES.flatMap((p) => p.labels))) {
|
||||
const res = await page.request.post('/api/addons/collections/labels', {
|
||||
data: { collection_id: collectionId, name, color: '#2563eb' },
|
||||
});
|
||||
expect(res.ok(), `label ${name}`).toBeTruthy();
|
||||
const label = await res.json();
|
||||
labelIds.set(name, (label.label ?? label).id);
|
||||
}
|
||||
for (const place of PLACES) {
|
||||
const res = await page.request.post('/api/addons/collections/places', {
|
||||
data: {
|
||||
collection_id: collectionId,
|
||||
name: place.name,
|
||||
address: 'Rua Anchieta 11, 1200-023 Lisboa, Portugal',
|
||||
lat: 38.71,
|
||||
lng: -9.14,
|
||||
},
|
||||
});
|
||||
expect(res.ok(), `place ${place.name}`).toBeTruthy();
|
||||
const saved = await res.json();
|
||||
if (!place.labels.length) continue;
|
||||
const assign = await page.request.post('/api/addons/collections/labels/assign', {
|
||||
data: { label_ids: place.labels.map((l) => labelIds.get(l)), place_ids: [(saved.place ?? saved).id] },
|
||||
});
|
||||
expect(assign.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
await page.goto(`/collections/${collectionId}`);
|
||||
const rows = page.locator('.col-lrow');
|
||||
await expect(rows).toHaveCount(PLACES.length);
|
||||
for (const place of PLACES) {
|
||||
const row = rows.filter({ hasText: place.name });
|
||||
const name = row.locator('.li .t');
|
||||
await expect(name).toHaveText(place.name);
|
||||
// The whole name fits: it is not cut to an ellipsis.
|
||||
expect(await name.evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
|
||||
await expectInside(row);
|
||||
// Every label is either on the row or counted by its "+N" chip.
|
||||
const shown = await row.locator('.col-lrow-label:not(.is-over)').count();
|
||||
// Read without waiting: a row whose labels all fit has no "+N".
|
||||
const [more] = await row.locator('.col-lrow-more').allTextContents();
|
||||
expect(shown + (more ? Number(more.replace('+', '')) : 0)).toBe(place.labels.length);
|
||||
}
|
||||
// The map's label strip stays inside the map: whole, or folded into its button.
|
||||
const topbar = page.locator('.col-map-topbar');
|
||||
if (await topbar.count()) await expectInside(topbar);
|
||||
} finally {
|
||||
if (collectionId !== undefined) {
|
||||
expect((await page.request.delete(`/api/addons/collections/${collectionId}`)).ok()).toBeTruthy();
|
||||
}
|
||||
expect(
|
||||
(await page.request.put('/api/admin/addons/collections', { data: { enabled: !!original.enabled } })).ok()
|
||||
).toBeTruthy();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -32,8 +32,8 @@ async function swipe(page: Page, from: { x: number; y: number }, to: { x: number
|
||||
}
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
|
||||
await cdp.detach()
|
||||
// The out leg is 150ms and the in leg 220ms.
|
||||
await page.waitForTimeout(500)
|
||||
// The strip snaps natively; give the snap and the selection that follows it time to land.
|
||||
await page.waitForTimeout(900)
|
||||
}
|
||||
|
||||
const activeChip = (page: Page) => page.locator('button[aria-current="true"]')
|
||||
@@ -72,19 +72,23 @@ test('#2051 phone: the day details area swipes between days', async ({ page }) =
|
||||
|
||||
const day1 = (await activeChip(page).textContent())!.trim()
|
||||
|
||||
// The days are a scroll-snap pager: like a native pager it turns once the
|
||||
// finger has carried the page past its middle, so the swipe covers most of it.
|
||||
const reach = Math.round(env.width * 0.4)
|
||||
const chipText = async () => (await activeChip(page).textContent())!.trim()
|
||||
|
||||
// 1. A swipe left steps forward.
|
||||
await swipe(page, { x: mid + 90, y }, { x: mid - 90, y })
|
||||
const day2 = (await activeChip(page).textContent())!.trim()
|
||||
expect(day2, 'swiping left moved to the next day').not.toBe(day1)
|
||||
await swipe(page, { x: mid + reach, y }, { x: mid - reach, y })
|
||||
await expect.poll(chipText, { message: 'swiping left moved to the next day' }).not.toBe(day1)
|
||||
|
||||
// 2. A swipe right steps back to where it started.
|
||||
await swipe(page, { x: mid - 90, y }, { x: mid + 90, y })
|
||||
expect((await activeChip(page).textContent())!.trim()).toBe(day1)
|
||||
await swipe(page, { x: mid - reach, y }, { x: mid + reach, y })
|
||||
await expect.poll(chipText).toBe(day1)
|
||||
|
||||
// 3. The first day has nowhere to go back to — it must stay put, and the
|
||||
// browser must not have been handed a page-back either.
|
||||
await swipe(page, { x: mid - 90, y }, { x: mid + 90, y })
|
||||
expect((await activeChip(page).textContent())!.trim()).toBe(day1)
|
||||
await swipe(page, { x: mid - reach, y }, { x: mid + reach, y })
|
||||
expect(await chipText()).toBe(day1)
|
||||
expect(page.url()).toContain(`/trips/${tripId}`)
|
||||
|
||||
// 4. A vertical drag belongs to the card's own scroller, never to the day.
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# 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.
|
||||
# "What to Test" comes from the release tag's message (native-release.yml). Apple only
|
||||
# takes it once the build is processed, so with notes the lane waits for that.
|
||||
notes = ENV.fetch("TESTFLIGHT_NOTES", "").strip
|
||||
upload_to_testflight(
|
||||
api_key: key,
|
||||
changelog: notes.empty? ? nil : notes,
|
||||
skip_waiting_for_build_processing: notes.empty?,
|
||||
skip_submission: true,
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -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,70 @@
|
||||
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 */; };
|
||||
D3C699E2258FBA6A11C1DDE4 /* TripIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8442B4B8988AF5D1BFA3ADE3 /* TripIntent.swift */; };
|
||||
157A7B737E3FFB610A218D47 /* AtlasContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D004544572B0E85DE29A7B8 /* AtlasContent.swift */; };
|
||||
FEB91385917AC44D5CDAA59C /* PackingContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04C0771C8582246A26E0C98E /* PackingContent.swift */; };
|
||||
EA5EDDD4E5F9BC9BC46C563F /* CollabContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D5041DDDAFF5B491B053999 /* CollabContent.swift */; };
|
||||
45B990B86488D5CC2B5222B5 /* WorldGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0AC751335E0136865E08990 /* WorldGrid.swift */; };
|
||||
1A0A6E71365C0A1178B85CB8 /* TrekStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */; };
|
||||
612BB9680DC27BB8BF553060 /* TrekFamily.swift in Sources */ = {isa = PBXBuildFile; fileRef = A030A06726542A2D7F61C54B /* TrekFamily.swift */; };
|
||||
8FC5873BE6A8CB2AFB31A46A /* TripContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */; };
|
||||
9A175689CFCC630BE9BB9EE1 /* TodayContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24B175DE91E1E6E850DB0285 /* TodayContent.swift */; };
|
||||
5A4D344328829E0F0B4D2B94 /* BookingsContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6D2F4930E5F152C8090C4F8 /* BookingsContent.swift */; };
|
||||
F1E29B18C5B1E75A5BC01B57 /* TodosContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEF7DCAA1AEE67F1291668D1 /* TodosContent.swift */; };
|
||||
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 */; };
|
||||
A0E8821CA7CF51AF2D6D8422 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 248A72D1C511B4969D89333A /* Localizable.strings */; };
|
||||
/* 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 +94,74 @@
|
||||
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>"; };
|
||||
8442B4B8988AF5D1BFA3ADE3 /* TripIntent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripIntent.swift; sourceTree = "<group>"; };
|
||||
4D004544572B0E85DE29A7B8 /* AtlasContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AtlasContent.swift; sourceTree = "<group>"; };
|
||||
04C0771C8582246A26E0C98E /* PackingContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PackingContent.swift; sourceTree = "<group>"; };
|
||||
7D5041DDDAFF5B491B053999 /* CollabContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CollabContent.swift; sourceTree = "<group>"; };
|
||||
D0AC751335E0136865E08990 /* WorldGrid.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WorldGrid.swift; sourceTree = "<group>"; };
|
||||
15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekStyle.swift; sourceTree = "<group>"; };
|
||||
A030A06726542A2D7F61C54B /* TrekFamily.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekFamily.swift; sourceTree = "<group>"; };
|
||||
DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripContent.swift; sourceTree = "<group>"; };
|
||||
24B175DE91E1E6E850DB0285 /* TodayContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TodayContent.swift; sourceTree = "<group>"; };
|
||||
E6D2F4930E5F152C8090C4F8 /* BookingsContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookingsContent.swift; sourceTree = "<group>"; };
|
||||
AEF7DCAA1AEE67F1291668D1 /* TodosContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TodosContent.swift; sourceTree = "<group>"; };
|
||||
FFA8802204810ED580DE46F3 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
|
||||
84CB5C3B6BD4E1DE1A8BD2B4 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
EE30A6DF943052C6ADE92D63 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
ECC80E1314B31ED2D2347411 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
7328217E23761C22814FEC25 /* az */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = az; path = az.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
CC077989456FB9DB5F4FF676 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt-BR; path = pt-BR.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
405B04DFF3A978D1046147C6 /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
DD341749E47B7DF89BA9A46D /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
9A3FAA9DBC67D12389EEF0CF /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
8D361F7B14AE9F5ACE3BFE78 /* et */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
60EB00F3670B41B898765DF9 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
B680CBF1268D2EE1982B165C /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
12DE24E0B7E2E5F3564D952B /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
15884CA899CAD3FFD98BA927 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
A2F3670F49C3D74C3D98E457 /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
1947CC62E5A6FF3483BEB578 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
8A0289CB321AE008D8815A19 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
B7F33B7A5C27C126DF29DCA4 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
683725459244601B71D92E2D /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
32A62EE30F8AD514D7DB2CAA /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
037DA05706AE051CEFCA7D75 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
090D74588DA8436DEC275567 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
9F756414EFBB3542F65C14CE /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
5C0109EA628FA897B7BFDD3E /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
E5800E4ECECED54A47CDABAA /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
A06BBDA4CF70465F2E3F21D3 /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
3CB565FC61AB5AEA87A3F615 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
608F00CBD96957EEA888E50A /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = zh-Hans; path = zh-Hans.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
1ED347C91E03E51FDF8ABC1A /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = zh-Hant; path = zh-Hant.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
/* 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 +170,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 +204,8 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
504EC3041FED79650016851F /* App.app */,
|
||||
A03A384FE54FAD1ED68E12DC /* NotificationService.appex */,
|
||||
702D0DF4A7E323CEA94CA03F /* TREKWidgets.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -88,6 +230,56 @@
|
||||
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 */,
|
||||
248A72D1C511B4969D89333A /* Localizable.strings */,
|
||||
C7064A9DA92FCC8ACAA4F22A /* NextTripWidget.swift */,
|
||||
BBB2E19CD540AB30B44BECCA /* TREKWidgets.entitlements */,
|
||||
8C2E47D0B005674C2B4ADA56 /* TREKWidgetsBundle.swift */,
|
||||
337775CD9091673D0ADDF325 /* TripDayAttributes.swift */,
|
||||
3259F6C40216CECEBAC6F307 /* TripDayLiveActivity.swift */,
|
||||
DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */,
|
||||
8442B4B8988AF5D1BFA3ADE3 /* TripIntent.swift */,
|
||||
4D004544572B0E85DE29A7B8 /* AtlasContent.swift */,
|
||||
04C0771C8582246A26E0C98E /* PackingContent.swift */,
|
||||
7D5041DDDAFF5B491B053999 /* CollabContent.swift */,
|
||||
D0AC751335E0136865E08990 /* WorldGrid.swift */,
|
||||
15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */,
|
||||
A030A06726542A2D7F61C54B /* TrekFamily.swift */,
|
||||
DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */,
|
||||
24B175DE91E1E6E850DB0285 /* TodayContent.swift */,
|
||||
E6D2F4930E5F152C8090C4F8 /* BookingsContent.swift */,
|
||||
AEF7DCAA1AEE67F1291668D1 /* TodosContent.swift */,
|
||||
);
|
||||
name = TREKWidgets;
|
||||
path = TREKWidgets;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -98,10 +290,13 @@
|
||||
504EC3001FED79650016851F /* Sources */,
|
||||
504EC3011FED79650016851F /* Frameworks */,
|
||||
504EC3021FED79650016851F /* Resources */,
|
||||
A8F12B42B22FCA041C6AA0DA /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
E23A0A46599B8EE7A5B74F45 /* PBXTargetDependency */,
|
||||
4CD7F5AF02CB806E4189489A /* PBXTargetDependency */,
|
||||
);
|
||||
name = App;
|
||||
packageProductDependencies = (
|
||||
@@ -111,6 +306,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 */
|
||||
@@ -139,6 +368,33 @@
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
de,
|
||||
ar,
|
||||
az,
|
||||
"pt-BR",
|
||||
ca,
|
||||
cs,
|
||||
es,
|
||||
et,
|
||||
fr,
|
||||
el,
|
||||
he,
|
||||
hu,
|
||||
id,
|
||||
it,
|
||||
ja,
|
||||
ko,
|
||||
nl,
|
||||
pl,
|
||||
ru,
|
||||
sk,
|
||||
sv,
|
||||
th,
|
||||
tr,
|
||||
uk,
|
||||
vi,
|
||||
"zh-Hans",
|
||||
"zh-Hant",
|
||||
);
|
||||
mainGroup = 504EC2FB1FED79650016851F;
|
||||
packageReferences = (
|
||||
@@ -149,11 +405,21 @@
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
504EC3031FED79650016851F /* App */,
|
||||
9480DFBC2C2A112973DA468A /* NotificationService */,
|
||||
A3BE5653156025C97CA62C68 /* TREKWidgets */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
04A03741C8AD60E63D4B11E7 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A0E8821CA7CF51AF2D6D8422 /* Localizable.strings in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
504EC3021FED79650016851F /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -168,9 +434,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,12 +460,86 @@
|
||||
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 */,
|
||||
D3C699E2258FBA6A11C1DDE4 /* TripIntent.swift in Sources */,
|
||||
157A7B737E3FFB610A218D47 /* AtlasContent.swift in Sources */,
|
||||
FEB91385917AC44D5CDAA59C /* PackingContent.swift in Sources */,
|
||||
EA5EDDD4E5F9BC9BC46C563F /* CollabContent.swift in Sources */,
|
||||
45B990B86488D5CC2B5222B5 /* WorldGrid.swift in Sources */,
|
||||
1A0A6E71365C0A1178B85CB8 /* TrekStyle.swift in Sources */,
|
||||
612BB9680DC27BB8BF553060 /* TrekFamily.swift in Sources */,
|
||||
8FC5873BE6A8CB2AFB31A46A /* TripContent.swift in Sources */,
|
||||
9A175689CFCC630BE9BB9EE1 /* TodayContent.swift in Sources */,
|
||||
5A4D344328829E0F0B4D2B94 /* BookingsContent.swift in Sources */,
|
||||
F1E29B18C5B1E75A5BC01B57 /* TodosContent.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 */
|
||||
248A72D1C511B4969D89333A /* Localizable.strings */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
84CB5C3B6BD4E1DE1A8BD2B4 /* en */,
|
||||
EE30A6DF943052C6ADE92D63 /* de */,
|
||||
ECC80E1314B31ED2D2347411 /* ar */,
|
||||
7328217E23761C22814FEC25 /* az */,
|
||||
CC077989456FB9DB5F4FF676 /* pt-BR */,
|
||||
405B04DFF3A978D1046147C6 /* ca */,
|
||||
DD341749E47B7DF89BA9A46D /* cs */,
|
||||
9A3FAA9DBC67D12389EEF0CF /* es */,
|
||||
8D361F7B14AE9F5ACE3BFE78 /* et */,
|
||||
60EB00F3670B41B898765DF9 /* fr */,
|
||||
B680CBF1268D2EE1982B165C /* el */,
|
||||
12DE24E0B7E2E5F3564D952B /* he */,
|
||||
15884CA899CAD3FFD98BA927 /* hu */,
|
||||
A2F3670F49C3D74C3D98E457 /* id */,
|
||||
1947CC62E5A6FF3483BEB578 /* it */,
|
||||
8A0289CB321AE008D8815A19 /* ja */,
|
||||
B7F33B7A5C27C126DF29DCA4 /* ko */,
|
||||
683725459244601B71D92E2D /* nl */,
|
||||
32A62EE30F8AD514D7DB2CAA /* pl */,
|
||||
037DA05706AE051CEFCA7D75 /* ru */,
|
||||
090D74588DA8436DEC275567 /* sk */,
|
||||
9F756414EFBB3542F65C14CE /* sv */,
|
||||
5C0109EA628FA897B7BFDD3E /* th */,
|
||||
E5800E4ECECED54A47CDABAA /* tr */,
|
||||
A06BBDA4CF70465F2E3F21D3 /* uk */,
|
||||
3CB565FC61AB5AEA87A3F615 /* vi */,
|
||||
608F00CBD96957EEA888E50A /* zh-Hans */,
|
||||
1ED347C91E03E51FDF8ABC1A /* zh-Hant */,
|
||||
);
|
||||
name = Localizable.strings;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
504EC30B1FED79650016851F /* Main.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
@@ -204,6 +559,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 = 17.0;
|
||||
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 +739,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 = 17.0;
|
||||
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 +846,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,15 @@
|
||||
import AuthenticationServices
|
||||
import Capacitor
|
||||
import CoreLocation
|
||||
import CryptoKit
|
||||
import Security
|
||||
import StoreKit
|
||||
import UserNotifications
|
||||
import WidgetKit
|
||||
#if canImport(ActivityKit)
|
||||
import ActivityKit
|
||||
#endif
|
||||
import LocalAuthentication
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
@@ -16,8 +26,236 @@ 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),
|
||||
CAPPluginMethod(name: "storeProducts", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "storePurchase", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "storeFinish", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "storeRestore", 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", "widgetTrips", "storeKit"]
|
||||
|
||||
// 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]) }
|
||||
}
|
||||
TrekStore.watchUpdates()
|
||||
}
|
||||
|
||||
// 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.
|
||||
/// `key` (feature widgetTrips) stores one trip's snapshot ("trip.<id>") or the
|
||||
/// list of trips a widget can be set to ("trips"); without it, the dashboard's.
|
||||
@objc func setWidgetSnapshot(_ call: CAPPluginCall) {
|
||||
guard let json = call.getString("json"), let data = json.data(using: .utf8) else {
|
||||
call.reject("json is required")
|
||||
return
|
||||
}
|
||||
let key = call.getString("key") ?? "snapshot"
|
||||
guard key == "snapshot" || key == "trips" || key.range(of: "^trip\\.[0-9]{1,12}$", options: .regularExpression) != nil else {
|
||||
call.reject("key must be snapshot, trips or trip.<id>")
|
||||
return
|
||||
}
|
||||
TrekSharedKeychain.write("widget.\(key)", 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 +378,350 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Positions and errors for the UI, shaped like the Geolocation API's, with the
|
||||
/// When-In-Use permission asked the first time.
|
||||
// MARK: - In-app purchase
|
||||
|
||||
extension TrekShellPlugin {
|
||||
/// Feature storeKit: the App Store's own price strings for the given products,
|
||||
/// already in the buyer's currency and format. Products the store does not
|
||||
/// know are left out, so the shell offers only what can really be bought.
|
||||
@objc func storeProducts(_ call: CAPPluginCall) {
|
||||
let ids = call.getArray("ids", String.self) ?? []
|
||||
Task {
|
||||
guard AppStore.canMakePayments, let products = try? await Product.products(for: ids) else {
|
||||
call.resolve(["products": []])
|
||||
return
|
||||
}
|
||||
call.resolve(["products": products.map { ["id": $0.id, "displayPrice": $0.displayPrice] }])
|
||||
}
|
||||
}
|
||||
|
||||
/// Feature storeKit: runs the App Store purchase sheet. Resolves with `kind`
|
||||
/// bought (with the signed transaction the control plane verifies), cancelled,
|
||||
/// pending (Ask to Buy, a payment the bank still has to approve) or failed.
|
||||
/// The transaction stays unfinished until storeFinish, so a purchase whose
|
||||
/// instance was never set up is delivered again rather than lost.
|
||||
@objc func storePurchase(_ call: CAPPluginCall) {
|
||||
guard let id = call.getString("productId") else {
|
||||
call.reject("productId is required")
|
||||
return
|
||||
}
|
||||
Task {
|
||||
do {
|
||||
guard let product = try await Product.products(for: [id]).first else {
|
||||
call.resolve(["kind": "failed"])
|
||||
return
|
||||
}
|
||||
switch try await product.purchase() {
|
||||
case .success(let verification):
|
||||
guard case .verified(let transaction) = verification else {
|
||||
call.resolve(["kind": "failed"])
|
||||
return
|
||||
}
|
||||
call.resolve([
|
||||
"kind": "bought",
|
||||
"jws": verification.jwsRepresentation,
|
||||
"transactionId": String(transaction.id),
|
||||
])
|
||||
case .userCancelled:
|
||||
call.resolve(["kind": "cancelled"])
|
||||
case .pending:
|
||||
call.resolve(["kind": "pending"])
|
||||
@unknown default:
|
||||
call.resolve(["kind": "failed"])
|
||||
}
|
||||
} catch {
|
||||
call.resolve(["kind": "failed"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Feature storeKit: marks a purchase as delivered, once its instance exists.
|
||||
@objc func storeFinish(_ call: CAPPluginCall) {
|
||||
guard let raw = call.getString("transactionId"), let id = UInt64(raw) else {
|
||||
call.reject("transactionId is required")
|
||||
return
|
||||
}
|
||||
Task {
|
||||
await TrekStore.finish(id)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
/// Feature storeKit: the newest active subscription on this Apple ID among
|
||||
/// products starting with `prefix`, after syncing with the App Store. Resolves
|
||||
/// without `jws` when there is none.
|
||||
@objc func storeRestore(_ call: CAPPluginCall) {
|
||||
let prefix = call.getString("prefix") ?? "cloud."
|
||||
Task {
|
||||
// Asks for the Apple ID password when needed; a cancel still leaves the local entitlements to check.
|
||||
try? await AppStore.sync()
|
||||
guard let found = await TrekStore.currentEntitlement(prefix: prefix) else {
|
||||
call.resolve([:])
|
||||
return
|
||||
}
|
||||
call.resolve(["jws": found.jws, "transactionId": String(found.id)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum TrekStore {
|
||||
private static var updates: Task<Void, Never>?
|
||||
|
||||
/// Listens for transactions that arrive outside a purchase sheet. Renewals are
|
||||
/// finished at once: the control plane learns of them from Apple's server
|
||||
/// notifications. A first purchase that lands here (Ask to Buy approved later,
|
||||
/// a purchase interrupted by the app closing) stays unfinished for the shell
|
||||
/// to redeem through a restore.
|
||||
static func watchUpdates() {
|
||||
guard updates == nil else { return }
|
||||
updates = Task.detached {
|
||||
for await result in Transaction.updates {
|
||||
guard case .verified(let transaction) = result else { continue }
|
||||
if transaction.originalID != transaction.id {
|
||||
await transaction.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func finish(_ id: UInt64) async {
|
||||
for await result in Transaction.unfinished {
|
||||
if case .verified(let transaction) = result, transaction.id == id {
|
||||
await transaction.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func currentEntitlement(prefix: String) async -> (jws: String, id: UInt64)? {
|
||||
var newest: (date: Date, jws: String, id: UInt64)?
|
||||
for await result in Transaction.currentEntitlements {
|
||||
guard case .verified(let transaction) = result,
|
||||
transaction.productID.hasPrefix(prefix),
|
||||
transaction.revocationDate == nil else { continue }
|
||||
if newest == nil || transaction.purchaseDate > newest!.date {
|
||||
newest = (transaction.purchaseDate, result.jwsRepresentation, transaction.id)
|
||||
}
|
||||
}
|
||||
return newest.map { ($0.jws, $0.id) }
|
||||
}
|
||||
}
|
||||
|
||||
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,253 @@
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
/// The Atlas on the home screen: the world as dots, the visited countries
|
||||
/// glowing, the latest one ringed, and how many there are.
|
||||
struct AtlasContent: View {
|
||||
let snapshot: WidgetSnapshot?
|
||||
let family: TrekFamily
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
let atlas = snapshot?.atlas
|
||||
let labels = snapshot?.labels
|
||||
let visited = Set(atlas?.codes ?? [])
|
||||
let latest = atlas?.recent.first?.code
|
||||
switch family {
|
||||
case .circular:
|
||||
ZStack {
|
||||
Circle().stroke(Color.white.opacity(0.25), lineWidth: 5)
|
||||
Circle().trim(from: 0, to: min(1, Double(atlas?.count ?? 0) / 195))
|
||||
.stroke(Color.white, style: StrokeStyle(lineWidth: 5, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
VStack(spacing: -2) {
|
||||
Image(systemName: "globe.europe.africa.fill").font(.system(size: 9, weight: .bold))
|
||||
Text("\(atlas?.count ?? 0)").font(Trek.number(19, .heavy))
|
||||
}
|
||||
}
|
||||
.padding(3)
|
||||
case .rectangular:
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "globe.europe.africa.fill").font(.system(size: 11, weight: .bold))
|
||||
Text("\(atlas?.count ?? 0) \(labels?.countries ?? "")").font(.system(size: 14, weight: .heavy))
|
||||
}
|
||||
DotWorld(visited: visited, latest: latest, accent: [.white], rest: Color.white.opacity(0.3), dot: 1.3)
|
||||
.frame(height: 34)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
case .inline:
|
||||
Text("\(Image(systemName: "globe.europe.africa.fill")) \(atlas?.count ?? 0) \(labels?.countries ?? "")")
|
||||
case .small:
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text("\(atlas?.count ?? 0)").font(Trek.number(38, .heavy)).foregroundStyle(ink)
|
||||
Text(labels?.countries ?? "").font(.system(size: 13, weight: .bold)).foregroundStyle(muted)
|
||||
}
|
||||
Text(share(atlas?.count ?? 0, labels)).font(.system(size: 11, weight: .semibold)).foregroundStyle(muted)
|
||||
Spacer(minLength: 6)
|
||||
DotWorld(visited: visited, latest: latest, accent: glow, rest: rest, dot: 1.35)
|
||||
.frame(height: 64)
|
||||
}
|
||||
case .medium:
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
DotWorld(visited: visited, latest: latest, accent: glow, rest: rest, dot: 2.2)
|
||||
.frame(maxHeight: .infinity)
|
||||
HStack(alignment: .lastTextBaseline, spacing: 6) {
|
||||
Text("\(atlas?.count ?? 0)").font(Trek.number(30, .heavy)).foregroundStyle(ink)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(labels?.countries ?? "").font(.system(size: 12, weight: .bold)).foregroundStyle(ink)
|
||||
Text(share(atlas?.count ?? 0, labels)).font(.system(size: 10.5, weight: .semibold)).foregroundStyle(muted)
|
||||
}
|
||||
Spacer(minLength: 4)
|
||||
if let recent = atlas?.recent.first {
|
||||
CountryPill(country: recent, highlight: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .lastTextBaseline, spacing: 6) {
|
||||
Text("\(atlas?.count ?? 0)").font(Trek.number(40, .heavy)).foregroundStyle(ink)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(labels?.countries ?? "").font(.system(size: 14, weight: .heavy)).foregroundStyle(ink)
|
||||
Text(share(atlas?.count ?? 0, labels)).font(.system(size: 11, weight: .semibold)).foregroundStyle(muted)
|
||||
}
|
||||
Spacer(minLength: 4)
|
||||
if let trips = atlas?.trips { Stat(value: "\(trips)", symbol: "airplane", scheme: scheme) }
|
||||
if let days = atlas?.days { Stat(value: "\(days)", symbol: "calendar", scheme: scheme) }
|
||||
}
|
||||
DotWorld(visited: visited, latest: latest, accent: glow, rest: rest, dot: 2.2)
|
||||
.frame(height: 150)
|
||||
ContinentBars(continents: atlas?.continents ?? [], scheme: scheme)
|
||||
HStack(spacing: 6) {
|
||||
ForEach(Array((atlas?.recent ?? []).prefix(3).enumerated()), id: \.offset) { index, country in
|
||||
CountryPill(country: country, highlight: index == 0)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var ink: Color { Trek.ink(scheme) }
|
||||
private var muted: Color { Trek.muted(scheme) }
|
||||
private var glow: [Color] { [Color(hex: 0x3DDC97), Color(hex: 0x4A7DDB)] }
|
||||
private var rest: Color { scheme == .dark ? Color.white.opacity(0.13) : Color(hex: 0x101013).opacity(0.11) }
|
||||
|
||||
/// "12 % of the world", of the 195 countries there are.
|
||||
private func share(_ count: Int, _ labels: WidgetSnapshot.Labels?) -> String {
|
||||
let percent = Int((Double(count) / 195 * 100).rounded())
|
||||
return "\(percent) % \(labels?.world ?? "")"
|
||||
}
|
||||
}
|
||||
|
||||
/// The world map as a grid of dots (WorldGrid): visited countries in the
|
||||
/// accent from west to east, the rest faint, the latest one ringed.
|
||||
struct DotWorld: View {
|
||||
let visited: Set<String>
|
||||
let latest: String?
|
||||
let accent: [Color]
|
||||
let rest: Color
|
||||
/// The dot radius at a 364 pt wide map; scaled with the width.
|
||||
var dot: CGFloat = 2
|
||||
|
||||
var body: some View {
|
||||
Canvas { context, size in
|
||||
let columns = CGFloat(WorldGrid.columns)
|
||||
let rows = CGFloat(WorldGrid.rows.count)
|
||||
let step = min(size.width / columns, size.height / rows)
|
||||
let originX = (size.width - step * columns) / 2
|
||||
let originY = (size.height - step * rows) / 2
|
||||
let radius = max(0.6, step * 0.36 * dot / 2)
|
||||
var ringed: CGPoint?
|
||||
var ringCount = 0
|
||||
var ringSum = CGPoint.zero
|
||||
for row in 0..<WorldGrid.rows.count {
|
||||
for column in 0..<WorldGrid.columns {
|
||||
guard let code = WorldGrid.code(row: row, column: column) else { continue }
|
||||
let center = CGPoint(x: originX + (CGFloat(column) + 0.5) * step, y: originY + (CGFloat(row) + 0.5) * step)
|
||||
let rect = CGRect(x: center.x - radius, y: center.y - radius, width: radius * 2, height: radius * 2)
|
||||
if visited.contains(code) {
|
||||
let t = CGFloat(column) / columns
|
||||
context.fill(Path(ellipseIn: rect.insetBy(dx: -radius * 0.25, dy: -radius * 0.25)), with: .color(blend(t)))
|
||||
if code == latest {
|
||||
ringSum.x += center.x
|
||||
ringSum.y += center.y
|
||||
ringCount += 1
|
||||
}
|
||||
} else {
|
||||
context.fill(Path(ellipseIn: rect), with: .color(rest))
|
||||
}
|
||||
}
|
||||
}
|
||||
if ringCount > 0 { ringed = CGPoint(x: ringSum.x / CGFloat(ringCount), y: ringSum.y / CGFloat(ringCount)) }
|
||||
if let ringed {
|
||||
let color = accent.last ?? .white
|
||||
for (scale, alpha) in [(5.5, 0.18), (3.6, 0.35)] as [(CGFloat, Double)] {
|
||||
let r = step * scale / 2
|
||||
context.stroke(
|
||||
Path(ellipseIn: CGRect(x: ringed.x - r, y: ringed.y - r, width: r * 2, height: r * 2)),
|
||||
with: .color(color.opacity(alpha)), lineWidth: max(1, step * 0.22)
|
||||
)
|
||||
}
|
||||
let core = step * 0.55
|
||||
context.fill(Path(ellipseIn: CGRect(x: ringed.x - core, y: ringed.y - core, width: core * 2, height: core * 2)), with: .color(.white))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The accent at a point from west (0) to east (1), blended between its colours.
|
||||
private func blend(_ t: CGFloat) -> Color {
|
||||
guard accent.count > 1, let from = rgb(accent[0]), let to = rgb(accent[accent.count - 1]) else { return accent.first ?? .white }
|
||||
let k = Double(max(0, min(1, t)))
|
||||
return Color(.sRGB, red: from.0 + (to.0 - from.0) * k, green: from.1 + (to.1 - from.1) * k, blue: from.2 + (to.2 - from.2) * k)
|
||||
}
|
||||
|
||||
private func rgb(_ color: Color) -> (Double, Double, Double)? {
|
||||
#if canImport(UIKit)
|
||||
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
||||
guard UIColor(color).getRed(&r, green: &g, blue: &b, alpha: &a) else { return nil }
|
||||
return (Double(r), Double(g), Double(b))
|
||||
#elseif canImport(AppKit)
|
||||
guard let c = NSColor(color).usingColorSpace(.sRGB) else { return nil }
|
||||
return (Double(c.redComponent), Double(c.greenComponent), Double(c.blueComponent))
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// A country with its flag, the latest one tinted.
|
||||
struct CountryPill: View {
|
||||
let country: WidgetSnapshot.Atlas.Country
|
||||
var highlight = false
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 5) {
|
||||
Text(flag(country.code)).font(.system(size: 13))
|
||||
Text(country.name).font(.system(size: 11.5, weight: .bold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
|
||||
}
|
||||
.padding(.horizontal, 9)
|
||||
.padding(.vertical, 4)
|
||||
.background(Capsule().fill(highlight ? Color(hex: 0x3DDC97).opacity(scheme == .dark ? 0.2 : 0.16) : Trek.card(scheme)))
|
||||
.overlay(Capsule().stroke(highlight ? Color(hex: 0x3DDC97).opacity(0.45) : Trek.cardBorder(scheme), lineWidth: 0.7))
|
||||
}
|
||||
|
||||
private func flag(_ code: String) -> String {
|
||||
code.uppercased().unicodeScalars.compactMap { UnicodeScalar(127_397 + $0.value) }.map { String($0) }.joined()
|
||||
}
|
||||
}
|
||||
|
||||
/// The continents as bars, longest first.
|
||||
struct ContinentBars: View {
|
||||
let continents: [WidgetSnapshot.Atlas.Continent]
|
||||
let scheme: ColorScheme
|
||||
|
||||
var body: some View {
|
||||
let top = continents.prefix(4)
|
||||
let most = max(top.map(\.count).max() ?? 1, 1)
|
||||
VStack(spacing: 6) {
|
||||
ForEach(Array(top.enumerated()), id: \.offset) { index, continent in
|
||||
HStack(spacing: 8) {
|
||||
Text(continent.name).font(.system(size: 11, weight: .bold)).foregroundStyle(Trek.ink(scheme))
|
||||
.frame(width: 92, alignment: .leading).lineLimit(1)
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(Trek.track(scheme))
|
||||
Capsule()
|
||||
.fill(LinearGradient(colors: [Color(hex: 0x3DDC97), Color(hex: 0x4A7DDB)], startPoint: .leading, endPoint: .trailing))
|
||||
.frame(width: max(6, geo.size.width * CGFloat(continent.count) / CGFloat(most)))
|
||||
.opacity(1 - Double(index) * 0.15)
|
||||
}
|
||||
}
|
||||
.frame(height: 6)
|
||||
Text("\(continent.count)").font(Trek.number(11, .heavy)).foregroundStyle(Trek.muted(scheme)).frame(width: 20, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A figure with its symbol, for the large atlas header.
|
||||
private struct Stat: View {
|
||||
let value: String
|
||||
let symbol: String
|
||||
let scheme: ColorScheme
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: symbol).font(.system(size: 10, weight: .bold))
|
||||
Text(value).font(Trek.number(12, .heavy))
|
||||
}
|
||||
.foregroundStyle(Trek.muted(scheme))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Capsule().fill(Trek.card(scheme)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import SwiftUI
|
||||
|
||||
/// What is booked next: flights, trains, the hotel, the table for tonight,
|
||||
/// each with its kind as a coloured tile and when it is due.
|
||||
struct BookingsContent: View {
|
||||
let snapshot: WidgetSnapshot?
|
||||
let family: TrekFamily
|
||||
let now: Date
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
let bookings = snapshot?.bookings ?? []
|
||||
let labels = snapshot?.labels
|
||||
if family == .rectangular {
|
||||
accessory(bookings.first, labels)
|
||||
} else if family == .circular {
|
||||
ZStack {
|
||||
Circle().fill(Color.white.opacity(0.18))
|
||||
Image(systemName: bookings.first.map { Trek.booking($0.kind).symbol } ?? "bookmark.fill")
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
}
|
||||
} else if family == .inline {
|
||||
if let first = bookings.first {
|
||||
Text("\(Image(systemName: Trek.booking(first.kind).symbol)) \(first.time ?? "") \(first.title)")
|
||||
} else {
|
||||
Text(labels?.noBookings ?? "")
|
||||
}
|
||||
} else if let labels, let first = bookings.first {
|
||||
switch family {
|
||||
case .small: hero(first, labels)
|
||||
case .medium: list(Array(bookings.prefix(2)), labels, featureFirst: true)
|
||||
default: list(Array(bookings.prefix(5)), labels, featureFirst: true)
|
||||
}
|
||||
} else {
|
||||
empty(labels)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Small: the next one, large
|
||||
|
||||
private func hero(_ booking: WidgetSnapshot.Booking, _ labels: WidgetSnapshot.Labels) -> some View {
|
||||
let look = Trek.booking(booking.kind)
|
||||
return VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
KindBadge(kind: booking.kind, size: 34)
|
||||
Spacer(minLength: 0)
|
||||
StatusDot(confirmed: booking.confirmed)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if let day = RelativeDay.label(booking.date, now: now, labels: labels) {
|
||||
Text(day)
|
||||
.font(.system(size: 11, weight: .heavy))
|
||||
.tracking(0.5)
|
||||
.textCase(.uppercase)
|
||||
.foregroundStyle(look.color)
|
||||
}
|
||||
if let time = booking.time {
|
||||
Text(time).font(Trek.number(30, .heavy)).foregroundStyle(Trek.ink(scheme))
|
||||
}
|
||||
Text(booking.title).font(.system(size: 14, weight: .bold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
|
||||
if let detail = booking.detail {
|
||||
Text(detail).font(.system(size: 11.5, weight: .semibold)).foregroundStyle(Trek.muted(scheme)).lineLimit(1)
|
||||
}
|
||||
}
|
||||
.background(alignment: .topTrailing) {
|
||||
Image(systemName: look.symbol)
|
||||
.font(.system(size: 92, weight: .bold))
|
||||
.foregroundStyle(look.color.opacity(scheme == .dark ? 0.1 : 0.08))
|
||||
.rotationEffect(.degrees(booking.kind == "flight" ? -20 : 0))
|
||||
.offset(x: 28, y: 12)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Medium and large: a list, the first one featured
|
||||
|
||||
private func list(_ bookings: [WidgetSnapshot.Booking], _ labels: WidgetSnapshot.Labels, featureFirst: Bool) -> some View {
|
||||
VStack(alignment: .leading, spacing: family == .large ? 8 : 7) {
|
||||
HStack {
|
||||
Text(labels.bookings ?? "").trekEyebrow(scheme)
|
||||
Spacer(minLength: 0)
|
||||
if let trip = snapshot?.trip { Text(trip.title).font(.system(size: 11, weight: .semibold)).foregroundStyle(Trek.faint(scheme)).lineLimit(1) }
|
||||
}
|
||||
ForEach(Array(bookings.enumerated()), id: \.offset) { index, booking in
|
||||
RowLink(path: booking.path) {
|
||||
if index == 0 && featureFirst {
|
||||
featured(booking, labels)
|
||||
} else {
|
||||
row(booking, labels)
|
||||
}
|
||||
}
|
||||
}
|
||||
if family == .large { Spacer(minLength: 0) }
|
||||
}
|
||||
}
|
||||
|
||||
private func featured(_ booking: WidgetSnapshot.Booking, _ labels: WidgetSnapshot.Labels) -> some View {
|
||||
let look = Trek.booking(booking.kind)
|
||||
return HStack(spacing: 10) {
|
||||
KindBadge(kind: booking.kind, size: 38)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(booking.title).font(.system(size: 15, weight: .heavy)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
|
||||
if let detail = booking.detail {
|
||||
Text(detail).font(.system(size: 12, weight: .semibold)).foregroundStyle(Trek.muted(scheme)).lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 4)
|
||||
VStack(alignment: .trailing, spacing: 0) {
|
||||
if let day = RelativeDay.label(booking.date, now: now, labels: labels) {
|
||||
Text(day).font(.system(size: 10.5, weight: .heavy)).textCase(.uppercase).foregroundStyle(look.color)
|
||||
}
|
||||
if let time = booking.time { Text(time).font(Trek.number(20, .heavy)).foregroundStyle(Trek.ink(scheme)) }
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(LinearGradient(colors: [look.color.opacity(scheme == .dark ? 0.24 : 0.14), look.color.opacity(scheme == .dark ? 0.08 : 0.04)], startPoint: .leading, endPoint: .trailing))
|
||||
)
|
||||
.overlay(RoundedRectangle(cornerRadius: 16, style: .continuous).stroke(look.color.opacity(0.25), lineWidth: 0.8))
|
||||
}
|
||||
|
||||
private func row(_ booking: WidgetSnapshot.Booking, _ labels: WidgetSnapshot.Labels) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
KindBadge(kind: booking.kind, size: 28)
|
||||
.padding(.leading, 5)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(spacing: 5) {
|
||||
Text(booking.title).font(.system(size: 13, weight: .bold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
|
||||
if !booking.confirmed { StatusDot(confirmed: false, size: 6) }
|
||||
}
|
||||
if let detail = booking.detail {
|
||||
Text(detail).font(.system(size: 11, weight: .medium)).foregroundStyle(Trek.muted(scheme)).lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 4)
|
||||
VStack(alignment: .trailing, spacing: 0) {
|
||||
if let day = RelativeDay.label(booking.date, now: now, labels: labels) {
|
||||
Text(day).font(.system(size: 10.5, weight: .bold)).foregroundStyle(Trek.muted(scheme))
|
||||
}
|
||||
if let time = booking.time { Text(time).font(Trek.number(13, .heavy)).foregroundStyle(Trek.ink(scheme)) }
|
||||
}
|
||||
.padding(.trailing, 5)
|
||||
}
|
||||
}
|
||||
|
||||
private func empty(_ labels: WidgetSnapshot.Labels?) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(labels?.bookings ?? "").trekEyebrow(scheme)
|
||||
Spacer(minLength: 0)
|
||||
HStack(spacing: -8) {
|
||||
ForEach(["flight", "train", "hotel"], id: \.self) { kind in
|
||||
KindBadge(kind: kind, size: 30).opacity(0.35)
|
||||
}
|
||||
}
|
||||
Text(labels?.noBookings ?? "")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(Trek.muted(scheme))
|
||||
.lineLimit(2)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func accessory(_ booking: WidgetSnapshot.Booking?, _ labels: WidgetSnapshot.Labels?) -> some View {
|
||||
if let booking, let labels {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: Trek.booking(booking.kind).symbol).font(.system(size: 11, weight: .bold))
|
||||
Text([RelativeDay.label(booking.date, now: now, labels: labels), booking.time].compactMap { $0 }.joined(separator: " "))
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.lineLimit(1)
|
||||
}
|
||||
Text(booking.title).font(.system(size: 14, weight: .heavy)).lineLimit(1)
|
||||
if let detail = booking.detail { Text(detail).font(.system(size: 11, weight: .medium)).opacity(0.75).lineLimit(1) }
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else {
|
||||
Text(labels?.noBookings ?? "").font(.system(size: 12, weight: .medium)).frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirmed in green, pending in amber, with a soft halo.
|
||||
struct StatusDot: View {
|
||||
let confirmed: Bool
|
||||
var size: CGFloat = 9
|
||||
|
||||
var body: some View {
|
||||
let color = confirmed ? Trek.confirmed : Trek.pending
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: size, height: size)
|
||||
.overlay(Circle().stroke(color.opacity(0.3), lineWidth: size * 0.45).frame(width: size * 1.7, height: size * 1.7))
|
||||
.frame(width: size * 1.8, height: size * 1.8)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The travel group: who comes along, the latest messages in the trip chat
|
||||
/// as bubbles, and the open poll with its votes as bars.
|
||||
struct CollabContent: View {
|
||||
let snapshot: WidgetSnapshot?
|
||||
let family: TrekFamily
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
let collab = snapshot?.collab
|
||||
let labels = snapshot?.labels
|
||||
if let collab, let labels {
|
||||
switch family {
|
||||
case .small:
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(labels.group ?? "").trekEyebrow(scheme)
|
||||
AvatarStack(members: collab.members, size: 30, scheme: scheme).padding(.top, 8)
|
||||
Spacer(minLength: 0)
|
||||
if let poll = collab.poll {
|
||||
Label(labels.poll ?? "", systemImage: "chart.bar.fill")
|
||||
.font(.system(size: 10.5, weight: .heavy))
|
||||
.foregroundStyle(Trek.info)
|
||||
Text(poll.question).font(.system(size: 13.5, weight: .bold)).foregroundStyle(Trek.ink(scheme)).lineLimit(2)
|
||||
} else if let last = collab.messages.last {
|
||||
Text(last.author).font(.system(size: 10.5, weight: .heavy)).foregroundStyle(Trek.muted(scheme))
|
||||
Text(last.text).font(.system(size: 13, weight: .semibold)).foregroundStyle(Trek.ink(scheme)).lineLimit(3)
|
||||
} else {
|
||||
Text(labels.noMessages ?? "").font(.system(size: 12.5, weight: .semibold)).foregroundStyle(Trek.muted(scheme))
|
||||
}
|
||||
}
|
||||
case .medium:
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text(snapshot?.trip?.title ?? labels.group ?? "").trekEyebrow(scheme)
|
||||
Spacer(minLength: 4)
|
||||
AvatarStack(members: collab.members, size: 22, scheme: scheme)
|
||||
}
|
||||
bubbles(Array(collab.messages.suffix(2)), labels)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
case .large:
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .center) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(labels.group ?? "").trekEyebrow(scheme)
|
||||
if let trip = snapshot?.trip {
|
||||
Text(trip.title).font(.system(size: 16, weight: .heavy)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 4)
|
||||
AvatarStack(members: collab.members, size: 28, scheme: scheme)
|
||||
}
|
||||
bubbles(Array(collab.messages.suffix(3)), labels)
|
||||
Spacer(minLength: 0)
|
||||
if let poll = collab.poll { PollCard(poll: poll, labels: labels, scheme: scheme) }
|
||||
}
|
||||
default:
|
||||
accessory(collab, labels)
|
||||
}
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(labels?.group ?? "").trekEyebrow(scheme)
|
||||
Spacer(minLength: 0)
|
||||
Image(systemName: "person.3.fill").font(.system(size: 28)).foregroundStyle(Trek.muted(scheme).opacity(0.5))
|
||||
Text(labels?.noMessages ?? "").font(.system(size: 13, weight: .semibold)).foregroundStyle(Trek.muted(scheme))
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func bubbles(_ messages: [WidgetSnapshot.Collab.Message], _ labels: WidgetSnapshot.Labels) -> some View {
|
||||
if messages.isEmpty {
|
||||
Text(labels.noMessages ?? "").font(.system(size: 13, weight: .semibold)).foregroundStyle(Trek.muted(scheme))
|
||||
} else {
|
||||
VStack(spacing: 6) {
|
||||
ForEach(Array(messages.enumerated()), id: \.offset) { _, message in
|
||||
ChatBubble(message: message, scheme: scheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func accessory(_ collab: WidgetSnapshot.Collab, _ labels: WidgetSnapshot.Labels) -> some View {
|
||||
if let last = collab.messages.last {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "bubble.left.and.bubble.right.fill").font(.system(size: 11, weight: .bold))
|
||||
Text(last.author).font(.system(size: 12, weight: .bold)).lineLimit(1)
|
||||
}
|
||||
Text(last.text).font(.system(size: 13, weight: .semibold)).lineLimit(2)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else {
|
||||
Text(labels.noMessages ?? "").font(.system(size: 12, weight: .medium)).frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Trek {
|
||||
/// A colour of a person's own, from their name.
|
||||
static func personColor(_ name: String) -> Color {
|
||||
let palette: [UInt32] = [0x4A7DDB, 0x2FA37A, 0xE8A13A, 0x8E6CD9, 0xD6408B, 0x2BA6A6, 0xD6673B, 0x5A6CDE]
|
||||
return Color(hex: palette[Int(seed(name) % UInt64(palette.count))])
|
||||
}
|
||||
}
|
||||
|
||||
/// Overlapping initials in each person's colour, with a count for the rest.
|
||||
struct AvatarStack: View {
|
||||
let members: [WidgetSnapshot.Collab.Member]
|
||||
let size: CGFloat
|
||||
let scheme: ColorScheme
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: -size * 0.18) {
|
||||
ForEach(Array(members.prefix(4).enumerated()), id: \.offset) { _, member in
|
||||
Circle()
|
||||
.fill(LinearGradient(colors: [Trek.personColor(member.name), Trek.personColor(member.name).opacity(0.75)], startPoint: .top, endPoint: .bottom))
|
||||
.frame(width: size, height: size)
|
||||
.overlay(Text(member.initials).font(.system(size: size * 0.34, weight: .heavy)).minimumScaleFactor(0.7).lineLimit(1).foregroundStyle(.white).padding(.horizontal, size * 0.1))
|
||||
.overlay(Circle().stroke(scheme == .dark ? Color(hex: 0x111115) : Color(hex: 0xF4F2EE), lineWidth: 2))
|
||||
}
|
||||
if members.count > 4 {
|
||||
Circle()
|
||||
.fill(Trek.card(scheme))
|
||||
.frame(width: size, height: size)
|
||||
.overlay(Text("+\(members.count - 4)").font(.system(size: size * 0.34, weight: .heavy)).foregroundStyle(Trek.muted(scheme)))
|
||||
.overlay(Circle().stroke(scheme == .dark ? Color(hex: 0x111115) : Color(hex: 0xF4F2EE), lineWidth: 2))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A chat message: the others on the left with their initials, mine on the right in ink.
|
||||
struct ChatBubble: View {
|
||||
let message: WidgetSnapshot.Collab.Message
|
||||
let scheme: ColorScheme
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 6) {
|
||||
if message.mine { Spacer(minLength: 36) }
|
||||
if !message.mine {
|
||||
Circle()
|
||||
.fill(Trek.personColor(message.author))
|
||||
.frame(width: 20, height: 20)
|
||||
.overlay(Text(message.initials).font(.system(size: 8, weight: .heavy)).foregroundStyle(.white))
|
||||
}
|
||||
Text(message.text)
|
||||
.font(.system(size: 12.5, weight: .medium))
|
||||
.foregroundStyle(message.mine ? (scheme == .dark ? Color(hex: 0x101013) : .white) : Trek.ink(scheme))
|
||||
.lineLimit(2)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(message.mine ? Trek.ink(scheme) : Trek.card(scheme))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.stroke(message.mine ? Color.clear : Trek.cardBorder(scheme), lineWidth: 0.7)
|
||||
)
|
||||
if !message.mine { Spacer(minLength: 36) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The open poll: the question and each option as a bar of its votes.
|
||||
struct PollCard: View {
|
||||
let poll: WidgetSnapshot.Collab.Poll
|
||||
let labels: WidgetSnapshot.Labels
|
||||
let scheme: ColorScheme
|
||||
|
||||
var body: some View {
|
||||
let most = max(poll.options.map(\.votes).max() ?? 1, 1)
|
||||
VStack(alignment: .leading, spacing: 7) {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: "chart.bar.fill").font(.system(size: 10, weight: .bold)).foregroundStyle(Trek.info)
|
||||
Text(labels.poll ?? "").font(.system(size: 10.5, weight: .heavy)).textCase(.uppercase).foregroundStyle(Trek.info)
|
||||
Spacer(minLength: 0)
|
||||
Text("\(poll.voters) \(labels.votes ?? "")").font(.system(size: 10.5, weight: .bold)).foregroundStyle(Trek.muted(scheme))
|
||||
}
|
||||
Text(poll.question).font(.system(size: 13.5, weight: .bold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
|
||||
ForEach(Array(poll.options.prefix(3).enumerated()), id: \.offset) { _, option in
|
||||
let leading = option.votes == most && option.votes > 0
|
||||
ZStack(alignment: .leading) {
|
||||
GeometryReader { geo in
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(leading ? Trek.info.opacity(scheme == .dark ? 0.35 : 0.22) : Trek.track(scheme))
|
||||
.frame(width: max(8, geo.size.width * CGFloat(option.votes) / CGFloat(most)))
|
||||
}
|
||||
HStack {
|
||||
Text(option.label).font(.system(size: 12, weight: leading ? .heavy : .semibold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
Text("\(option.votes)").font(Trek.number(12, .heavy)).foregroundStyle(Trek.muted(scheme))
|
||||
}
|
||||
.padding(.horizontal, 9)
|
||||
}
|
||||
.frame(height: 24)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(RoundedRectangle(cornerRadius: 15, style: .continuous).fill(Trek.card(scheme)))
|
||||
.overlay(RoundedRectangle(cornerRadius: 15, style: .continuous).stroke(Trek.cardBorder(scheme), lineWidth: 0.7))
|
||||
}
|
||||
}
|
||||
@@ -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,249 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
/// The home-screen and lock-screen widgets; all but the Atlas show one trip,
|
||||
/// the dashboard's or the one picked in Edit Widget (TripIntent.swift). Their gallery names and
|
||||
/// descriptions are in <language>.lproj/Localizable.strings. Each reads the snapshot the app
|
||||
/// last wrote (WidgetSnapshot) and draws it with the views in *Content.swift,
|
||||
/// which know nothing of WidgetKit so the preview renderer can draw them too.
|
||||
struct TrekEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let snapshot: WidgetSnapshot?
|
||||
}
|
||||
|
||||
struct TrekProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> TrekEntry {
|
||||
TrekEntry(date: Date(), snapshot: .sample(running: false))
|
||||
}
|
||||
|
||||
/// The gallery shows the user's own trip when the app has written one, a
|
||||
/// sample trip before that.
|
||||
func getSnapshot(in context: Context, completion: @escaping (TrekEntry) -> Void) {
|
||||
let stored = WidgetSnapshot.load()
|
||||
let snapshot: WidgetSnapshot? = (stored?.trip != nil || !context.isPreview) ? stored : WidgetSnapshot.sample(running: false)
|
||||
completion(TrekEntry(date: Date(), snapshot: snapshot))
|
||||
}
|
||||
|
||||
/// An entry every quarter hour for the next six hours, so the now line and
|
||||
/// the next stop move on without the app; the app reloads the timeline
|
||||
/// whenever it writes a new snapshot.
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<TrekEntry>) -> Void) {
|
||||
completion(TrekProvider.timeline(WidgetSnapshot.load()))
|
||||
}
|
||||
|
||||
static func timeline(_ snapshot: WidgetSnapshot?) -> Timeline<TrekEntry> {
|
||||
let now = Date()
|
||||
let calendar = Calendar.current
|
||||
let minute = calendar.component(.minute, from: now)
|
||||
let firstStep = calendar.date(byAdding: .minute, value: 15 - minute % 15, to: now) ?? now
|
||||
var entries = [TrekEntry(date: now, snapshot: snapshot)]
|
||||
for step in 0..<24 {
|
||||
if let date = calendar.date(byAdding: .minute, value: step * 15, to: firstStep) {
|
||||
entries.append(TrekEntry(date: calendar.date(bySetting: .second, value: 0, of: date) ?? date, snapshot: snapshot))
|
||||
}
|
||||
}
|
||||
return Timeline(entries: entries, policy: .atEnd)
|
||||
}
|
||||
}
|
||||
|
||||
extension TrekFamily {
|
||||
init(_ family: WidgetFamily) {
|
||||
switch family {
|
||||
case .systemSmall: self = .small
|
||||
case .systemMedium: self = .medium
|
||||
case .accessoryCircular: self = .circular
|
||||
case .accessoryRectangular: self = .rectangular
|
||||
case .accessoryInline: self = .inline
|
||||
default: self = .large
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The widget's ground: the content on its background, through the container
|
||||
/// background on iOS 17 (which the system can tint and remove), the plain way before.
|
||||
struct TrekContainer<Content: View, Background: View>: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
let link: URL?
|
||||
@ViewBuilder var content: () -> Content
|
||||
@ViewBuilder var background: () -> Background
|
||||
|
||||
var body: some View {
|
||||
let accessory = TrekFamily(family).isAccessory
|
||||
Group {
|
||||
if #available(iOSApplicationExtension 17.0, *) {
|
||||
content().containerBackground(for: .widget) {
|
||||
if accessory { Color.clear } else { background() }
|
||||
}
|
||||
} else {
|
||||
content().padding(accessory ? 0 : 16).background(accessory ? nil : background())
|
||||
}
|
||||
}
|
||||
.widgetURL(link)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Trip
|
||||
|
||||
struct NextTripWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
// The kind stays "NextTrip" so widgets placed before the redesign keep working.
|
||||
AppIntentConfiguration(kind: "NextTrip", intent: SelectTripIntent.self, provider: TrekTripProvider()) { entry in
|
||||
TripWidgetView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("widget.trip.name")
|
||||
.description("widget.trip.description")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryCircular, .accessoryRectangular, .accessoryInline])
|
||||
}
|
||||
}
|
||||
|
||||
struct TripWidgetView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
let entry: TrekEntry
|
||||
|
||||
var body: some View {
|
||||
let snapshot = entry.snapshot
|
||||
TrekContainer(link: snapshot?.trip.flatMap { WidgetSnapshot.link($0.path) }) {
|
||||
TripContent(snapshot: snapshot, family: TrekFamily(family), now: entry.date)
|
||||
} background: {
|
||||
if snapshot?.trip != nil { TripBackdrop(trip: snapshot?.trip) } else { TrekPaper() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Today
|
||||
|
||||
struct TodayWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(kind: "TripToday", intent: SelectTripIntent.self, provider: TrekTripProvider()) { entry in
|
||||
TrekContainer(link: entry.snapshot?.trip.flatMap { WidgetSnapshot.link($0.path) }) {
|
||||
TodayFamilyView(entry: entry)
|
||||
} background: {
|
||||
TrekPaper()
|
||||
}
|
||||
}
|
||||
.configurationDisplayName("widget.today.name")
|
||||
.description("widget.today.description")
|
||||
.supportedFamilies([.systemMedium, .systemLarge])
|
||||
}
|
||||
}
|
||||
|
||||
private struct TodayFamilyView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
let entry: TrekEntry
|
||||
var body: some View { TodayContent(snapshot: entry.snapshot, family: TrekFamily(family), now: entry.date) }
|
||||
}
|
||||
|
||||
// MARK: - Bookings
|
||||
|
||||
struct BookingsWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(kind: "TripBookings", intent: SelectTripIntent.self, provider: TrekTripProvider()) { entry in
|
||||
TrekContainer(link: entry.snapshot?.bookings?.first.flatMap { WidgetSnapshot.link($0.path) }) {
|
||||
BookingsFamilyView(entry: entry)
|
||||
} background: {
|
||||
TrekPaper()
|
||||
}
|
||||
}
|
||||
.configurationDisplayName("widget.bookings.name")
|
||||
.description("widget.bookings.description")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryRectangular, .accessoryInline])
|
||||
}
|
||||
}
|
||||
|
||||
private struct BookingsFamilyView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
let entry: TrekEntry
|
||||
var body: some View { BookingsContent(snapshot: entry.snapshot, family: TrekFamily(family), now: entry.date) }
|
||||
}
|
||||
|
||||
// MARK: - To-dos
|
||||
|
||||
struct TodosWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(kind: "TripTodos", intent: SelectTripIntent.self, provider: TrekTripProvider()) { entry in
|
||||
TrekContainer(link: entry.snapshot?.todos.flatMap { WidgetSnapshot.link($0.path) }) {
|
||||
TodosFamilyView(entry: entry)
|
||||
} background: {
|
||||
TrekPaper()
|
||||
}
|
||||
}
|
||||
.configurationDisplayName("widget.todos.name")
|
||||
.description("widget.todos.description")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryCircular, .accessoryRectangular])
|
||||
}
|
||||
}
|
||||
|
||||
private struct TodosFamilyView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
let entry: TrekEntry
|
||||
var body: some View { TodosContent(snapshot: entry.snapshot, family: TrekFamily(family), now: entry.date) }
|
||||
}
|
||||
|
||||
// MARK: - Packing
|
||||
|
||||
struct PackingWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(kind: "TripPacking", intent: SelectTripIntent.self, provider: TrekTripProvider()) { entry in
|
||||
TrekContainer(link: entry.snapshot?.packing.flatMap { WidgetSnapshot.link($0.path) }) {
|
||||
PackingFamilyView(entry: entry)
|
||||
} background: {
|
||||
TrekPaper()
|
||||
}
|
||||
}
|
||||
.configurationDisplayName("widget.packing.name")
|
||||
.description("widget.packing.description")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryCircular, .accessoryRectangular])
|
||||
}
|
||||
}
|
||||
|
||||
private struct PackingFamilyView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
let entry: TrekEntry
|
||||
var body: some View { PackingContent(snapshot: entry.snapshot, family: TrekFamily(family), now: entry.date) }
|
||||
}
|
||||
|
||||
// MARK: - Atlas
|
||||
|
||||
struct AtlasWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: "TripAtlas", provider: TrekProvider()) { entry in
|
||||
TrekContainer(link: WidgetSnapshot.link("/atlas")) {
|
||||
AtlasFamilyView(entry: entry)
|
||||
} background: {
|
||||
TrekPaper(contours: false)
|
||||
}
|
||||
}
|
||||
.configurationDisplayName("widget.atlas.name")
|
||||
.description("widget.atlas.description")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryCircular, .accessoryRectangular, .accessoryInline])
|
||||
}
|
||||
}
|
||||
|
||||
private struct AtlasFamilyView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
let entry: TrekEntry
|
||||
var body: some View { AtlasContent(snapshot: entry.snapshot, family: TrekFamily(family)) }
|
||||
}
|
||||
|
||||
// MARK: - Travel group
|
||||
|
||||
struct CollabWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(kind: "TripCollab", intent: SelectTripIntent.self, provider: TrekTripProvider()) { entry in
|
||||
TrekContainer(link: entry.snapshot?.collab.flatMap { WidgetSnapshot.link($0.path) }) {
|
||||
CollabFamilyView(entry: entry)
|
||||
} background: {
|
||||
TrekPaper()
|
||||
}
|
||||
}
|
||||
.configurationDisplayName("widget.collab.name")
|
||||
.description("widget.collab.description")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryRectangular])
|
||||
}
|
||||
}
|
||||
|
||||
private struct CollabFamilyView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
let entry: TrekEntry
|
||||
var body: some View { CollabContent(snapshot: entry.snapshot, family: TrekFamily(family)) }
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The packing list: how much is in the bags, each bag against its weight
|
||||
/// limit, and what is still missing, with a nudge when departure is close.
|
||||
struct PackingContent: View {
|
||||
let snapshot: WidgetSnapshot?
|
||||
let family: TrekFamily
|
||||
let now: Date
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
let packing = snapshot?.packing
|
||||
let labels = snapshot?.labels
|
||||
if family.isAccessory {
|
||||
accessory(packing, labels)
|
||||
} else if let packing, let labels, packing.total > 0 {
|
||||
let missing = packing.total - packing.packed
|
||||
switch family {
|
||||
case .small:
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header(labels)
|
||||
Spacer(minLength: 0)
|
||||
HStack {
|
||||
Spacer(minLength: 0)
|
||||
ring(packing, size: 88)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
Text("\(missing) \(labels.missing ?? "")")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(Trek.muted(scheme))
|
||||
.lineLimit(1)
|
||||
}
|
||||
case .medium:
|
||||
HStack(spacing: 14) {
|
||||
ring(packing, size: 104)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
header(labels)
|
||||
missingList(Array((packing.missing ?? []).prefix(4)))
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
default:
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .center, spacing: 14) {
|
||||
ring(packing, size: 86)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
header(labels)
|
||||
if let trip = snapshot?.trip {
|
||||
Text(trip.title).font(.system(size: 15, weight: .heavy)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
|
||||
}
|
||||
Text("\(missing) \(labels.missing ?? "")")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(Trek.muted(scheme))
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
VStack(spacing: 7) {
|
||||
ForEach(Array((packing.bags ?? []).prefix(3).enumerated()), id: \.offset) { _, bag in
|
||||
BagCard(bag: bag, scheme: scheme)
|
||||
}
|
||||
}
|
||||
missingGrid(Array((packing.missing ?? []).prefix(8)))
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
header(labels)
|
||||
Spacer(minLength: 0)
|
||||
Image(systemName: "suitcase.rolling.fill").font(.system(size: 32)).foregroundStyle(Trek.muted(scheme).opacity(0.5))
|
||||
Text(labels?.noTodos ?? "").font(.system(size: 13, weight: .semibold)).foregroundStyle(Trek.muted(scheme))
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
/// The days to departure while it is two or fewer away.
|
||||
private var departureSoon: Int? {
|
||||
guard snapshot?.today == nil, let days = snapshot?.daysUntilStart(from: now), (0...2).contains(days) else { return nil }
|
||||
return days
|
||||
}
|
||||
|
||||
private func header(_ labels: WidgetSnapshot.Labels?) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
Text(labels?.packing ?? "").trekEyebrow(scheme)
|
||||
Spacer(minLength: 0)
|
||||
if let days = departureSoon, let labels {
|
||||
Text(days == 0 ? (labels.todayShort ?? labels.today) : days == 1 ? labels.tomorrow : labels.inDays.replacingOccurrences(of: "{n}", with: "\(days)"))
|
||||
.font(.system(size: 10, weight: .heavy))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 2)
|
||||
.background(Capsule().fill(Trek.pending))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func ring(_ packing: WidgetSnapshot.Packing, size: CGFloat) -> some View {
|
||||
let share = Double(packing.packed) / Double(max(packing.total, 1))
|
||||
return ProgressRing(value: share, lineWidth: size * 0.1, colors: [Color(hex: 0x2FA37A), Color(hex: 0x7BC96F)], trackColor: Trek.track(scheme)) {
|
||||
VStack(spacing: -1) {
|
||||
Image(systemName: "suitcase.rolling.fill").font(.system(size: size * 0.16, weight: .semibold)).foregroundStyle(Trek.confirmed)
|
||||
Text("\(Int((share * 100).rounded()))%").font(Trek.number(size * 0.22, .heavy)).foregroundStyle(Trek.ink(scheme))
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
|
||||
private func missingList(_ items: [WidgetSnapshot.Packing.Item]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(Array(items.enumerated()), id: \.offset) { _, item in
|
||||
HStack(spacing: 8) {
|
||||
RoundedRectangle(cornerRadius: 4, style: .continuous)
|
||||
.stroke(Color(css: item.color) ?? Trek.faint(scheme), lineWidth: 1.7)
|
||||
.frame(width: 13, height: 13)
|
||||
Text(item.name).font(.system(size: 13, weight: .semibold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func missingGrid(_ items: [WidgetSnapshot.Packing.Item]) -> some View {
|
||||
LazyVGrid(columns: [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)], alignment: .leading, spacing: 7) {
|
||||
ForEach(Array(items.enumerated()), id: \.offset) { _, item in
|
||||
HStack(spacing: 7) {
|
||||
RoundedRectangle(cornerRadius: 4, style: .continuous)
|
||||
.stroke(Color(css: item.color) ?? Trek.faint(scheme), lineWidth: 1.6)
|
||||
.frame(width: 12, height: 12)
|
||||
Text(item.name).font(.system(size: 12, weight: .semibold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func accessory(_ packing: WidgetSnapshot.Packing?, _ labels: WidgetSnapshot.Labels?) -> some View {
|
||||
let total = packing?.total ?? 0
|
||||
let packed = packing?.packed ?? 0
|
||||
switch family {
|
||||
case .circular:
|
||||
ZStack {
|
||||
Circle().stroke(Color.white.opacity(0.25), lineWidth: 5)
|
||||
Circle().trim(from: 0, to: total > 0 ? Double(packed) / Double(total) : 0)
|
||||
.stroke(Color.white, style: StrokeStyle(lineWidth: 5, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
VStack(spacing: -2) {
|
||||
Image(systemName: "suitcase.rolling.fill").font(.system(size: 9, weight: .bold))
|
||||
Text("\(total - packed)").font(Trek.number(18, .heavy))
|
||||
}
|
||||
}
|
||||
.padding(3)
|
||||
default:
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "suitcase.rolling.fill").font(.system(size: 11, weight: .bold))
|
||||
Text("\(labels?.packing ?? "") \(packed)/\(total)").font(.system(size: 12, weight: .bold))
|
||||
}
|
||||
ForEach(Array((packing?.missing ?? []).prefix(2).enumerated()), id: \.offset) { _, item in
|
||||
Text("\(Image(systemName: "square")) \(item.name)").font(.system(size: 12, weight: .medium)).lineLimit(1)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A bag in its colour: how full, and its weight against the limit.
|
||||
struct BagCard: View {
|
||||
let bag: WidgetSnapshot.Packing.Bag
|
||||
let scheme: ColorScheme
|
||||
|
||||
var body: some View {
|
||||
let color = Color(css: bag.color) ?? Trek.info
|
||||
let share = bag.limit.map { Double(bag.grams) / Double(max($0, 1)) }
|
||||
HStack(spacing: 10) {
|
||||
RoundedRectangle(cornerRadius: 9, style: .continuous)
|
||||
.fill(LinearGradient(colors: [color, color.opacity(0.75)], startPoint: .top, endPoint: .bottom))
|
||||
.frame(width: 30, height: 30)
|
||||
.overlay(Image(systemName: "bag.fill").font(.system(size: 13, weight: .semibold)).foregroundStyle(.white))
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(bag.name).font(.system(size: 12.5, weight: .bold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
Text(weight(bag)).font(Trek.number(11.5, .heavy)).foregroundStyle((share ?? 0) > 1 ? Trek.danger : Trek.muted(scheme))
|
||||
}
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(Trek.track(scheme))
|
||||
Capsule()
|
||||
.fill((share ?? 0) > 1 ? Trek.danger : color)
|
||||
.frame(width: max(5, geo.size.width * CGFloat(min(share ?? Double(bag.packed) / Double(max(bag.total, 1)), 1))))
|
||||
}
|
||||
}
|
||||
.frame(height: 5)
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.background(RoundedRectangle(cornerRadius: 13, style: .continuous).fill(Trek.card(scheme)))
|
||||
.overlay(RoundedRectangle(cornerRadius: 13, style: .continuous).stroke(Trek.cardBorder(scheme), lineWidth: 0.7))
|
||||
}
|
||||
|
||||
/// "14.2 / 23 kg", or the packed count when the bag has no limit.
|
||||
private func weight(_ bag: WidgetSnapshot.Packing.Bag) -> String {
|
||||
let kg = { (grams: Int) in String(format: "%.1f", Double(grams) / 1000) }
|
||||
if let limit = bag.limit { return "\(kg(bag.grams)) / \(kg(limit)) kg" }
|
||||
return "\(bag.packed)/\(bag.total)"
|
||||
}
|
||||
}
|
||||
@@ -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,19 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
/// The TREK widgets: the trip, today's plan, the bookings ahead, the to-dos,
|
||||
/// the packing list, the Atlas and the travel group on the home and lock
|
||||
/// screen, and the travel day as a Live Activity.
|
||||
@main
|
||||
struct TREKWidgetsBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
NextTripWidget()
|
||||
TodayWidget()
|
||||
BookingsWidget()
|
||||
TodosWidget()
|
||||
PackingWidget()
|
||||
AtlasWidget()
|
||||
CollabWidget()
|
||||
TripDayLiveActivity()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Today's plan on paper: the day, the weather, and the stops as a timeline
|
||||
/// with a now line, so a glance says what is next and what is behind.
|
||||
struct TodayContent: View {
|
||||
let snapshot: WidgetSnapshot?
|
||||
let family: TrekFamily
|
||||
let now: Date
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
let nowMinutes = Calendar.current.component(.hour, from: now) * 60 + Calendar.current.component(.minute, from: now)
|
||||
if let snapshot, let trip = snapshot.trip, let today = snapshot.today {
|
||||
let total = today.total ?? snapshot.tripLength ?? 1
|
||||
let timed = today.stops.filter { $0.minutes != nil }
|
||||
let done = timed.filter { ($0.minutes ?? 0) < nowMinutes }.count
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
DayBadge(index: today.index ?? 1, total: total)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(trip.title).trekEyebrow(scheme)
|
||||
Text(today.dayLabel)
|
||||
.font(.system(size: family == .large ? 19 : 16, weight: .heavy))
|
||||
.foregroundStyle(Trek.ink(scheme))
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if let weather = snapshot.weather {
|
||||
if family == .large {
|
||||
PaperWeather(weather: weather)
|
||||
} else {
|
||||
WeatherChip(weather: weather)
|
||||
}
|
||||
}
|
||||
}
|
||||
if family == .large {
|
||||
DayProgress(done: done, total: timed.count, scheme: scheme, label: snapshot.labels.allDone)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 14)
|
||||
} else {
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
StopList(
|
||||
stops: today.stops, nowMinutes: nowMinutes, limit: family == .large ? 7 : 3,
|
||||
nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now
|
||||
)
|
||||
if family == .large {
|
||||
Spacer(minLength: 8)
|
||||
if let booking = snapshot.bookings?.first {
|
||||
NextBookingStrip(booking: booking, labels: snapshot.labels, now: now, onPhoto: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let snapshot, let trip = snapshot.trip {
|
||||
// Before the trip there is no day yet: count down to it instead.
|
||||
let days = max(snapshot.daysUntilStart(from: now) ?? 0, 0)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(snapshot.labels.nextTrip).trekEyebrow(scheme)
|
||||
Text(trip.title).font(.system(size: 17, weight: .heavy)).foregroundStyle(Trek.ink(scheme)).lineLimit(2)
|
||||
Spacer(minLength: 0)
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text("\(days)").font(Trek.number(40, .heavy)).foregroundStyle(Trek.ink(scheme))
|
||||
Text(snapshot.labels.days ?? "").font(.system(size: 13, weight: .bold)).foregroundStyle(Trek.muted(scheme))
|
||||
}
|
||||
RouteArc(progress: 1 - min(Double(days), 60) / 60, color: Trek.ink(scheme)).frame(height: 24)
|
||||
}
|
||||
} else {
|
||||
EmptyTrip(text: snapshot?.labels.noTrip ?? "", family: family)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Today's number in a dark tile with the trip's length under it.
|
||||
struct DayBadge: View {
|
||||
let index: Int
|
||||
let total: Int
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: -2) {
|
||||
Text("\(index)").font(Trek.number(19, .heavy))
|
||||
Text("/\(total)").font(Trek.number(9, .bold)).opacity(0.7)
|
||||
}
|
||||
.foregroundStyle(scheme == .dark ? Color(hex: 0x101013) : .white)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Trek.ink(scheme))
|
||||
)
|
||||
.shadow(color: .black.opacity(scheme == .dark ? 0 : 0.18), radius: 5, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
/// The weather on paper: symbol, temperature, and the day's range and rain.
|
||||
struct PaperWeather: View {
|
||||
let weather: WidgetSnapshot.Weather
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: Trek.weatherSymbol(weather.main))
|
||||
.symbolRenderingMode(.multicolor)
|
||||
.font(.system(size: 24))
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("\(weather.temp)°").font(Trek.number(20, .heavy)).foregroundStyle(Trek.ink(scheme))
|
||||
HStack(spacing: 4) {
|
||||
if let high = weather.high, let low = weather.low { Text("\(high)°/\(low)°") }
|
||||
if let rain = weather.rain, rain > 0 {
|
||||
Text("\(Image(systemName: "drop.fill"))\(rain)%")
|
||||
}
|
||||
}
|
||||
.font(Trek.number(10, .semibold))
|
||||
.foregroundStyle(Trek.muted(scheme))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How much of the day is behind: a bar of the stops passed.
|
||||
struct DayProgress: View {
|
||||
let done: Int
|
||||
let total: Int
|
||||
let scheme: ColorScheme
|
||||
let label: String?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(Trek.track(scheme))
|
||||
Capsule()
|
||||
.fill(LinearGradient(colors: [Trek.confirmed.opacity(0.8), Trek.confirmed], startPoint: .leading, endPoint: .trailing))
|
||||
.frame(width: total > 0 ? max(6, geo.size.width * CGFloat(done) / CGFloat(total)) : 0)
|
||||
}
|
||||
}
|
||||
.frame(height: 6)
|
||||
Text(done >= total && total > 0 ? (label ?? "\(done)/\(total)") : "\(done)/\(total)")
|
||||
.font(Trek.number(11, .bold))
|
||||
.foregroundStyle(Trek.muted(scheme))
|
||||
.lineLimit(1)
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The trip's open to-dos and the packing list: how far along both are, as
|
||||
/// rings and a bar, and the to-dos that matter most, by due date and priority.
|
||||
struct TodosContent: View {
|
||||
let snapshot: WidgetSnapshot?
|
||||
let family: TrekFamily
|
||||
let now: Date
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
let todos = snapshot?.todos
|
||||
let labels = snapshot?.labels
|
||||
if family.isAccessory {
|
||||
accessory(todos, labels)
|
||||
} else if let labels, let todos, todos.total > 0 {
|
||||
switch family {
|
||||
case .small: small(todos, labels)
|
||||
case .medium:
|
||||
HStack(spacing: 14) {
|
||||
rings(todos, labels, size: 104)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header(labels, todos)
|
||||
items(Array(todos.items.prefix(3)), labels).padding(.top, 6)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
default:
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .center, spacing: 14) {
|
||||
rings(todos, labels, size: 96)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
header(labels, todos)
|
||||
}
|
||||
}
|
||||
Divider().overlay(Trek.track(scheme)).padding(.vertical, 10)
|
||||
items(Array(todos.items.prefix(7)), labels)
|
||||
Spacer(minLength: 8)
|
||||
legend(todos, labels)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(labels?.todos ?? "").trekEyebrow(scheme)
|
||||
Spacer(minLength: 0)
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.system(size: 34))
|
||||
.foregroundStyle(Trek.confirmed.opacity(0.85))
|
||||
Text(labels?.noTodos ?? "").font(.system(size: 14, weight: .bold)).foregroundStyle(Trek.ink(scheme))
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private func small(_ todos: WidgetSnapshot.Todos, _ labels: WidgetSnapshot.Labels) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
Text(labels.todos ?? "").trekEyebrow(scheme)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
HStack {
|
||||
Spacer(minLength: 0)
|
||||
rings(todos, labels, size: 92)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if let first = todos.items.first {
|
||||
HStack(spacing: 5) {
|
||||
Circle().stroke(Trek.priority(first.priority) ?? Trek.faint(scheme), lineWidth: 1.6).frame(width: 10, height: 10)
|
||||
Text(first.name).font(.system(size: 12, weight: .semibold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The share of the to-dos done as a ring, the open ones in the middle.
|
||||
private func rings(_ todos: WidgetSnapshot.Todos, _ labels: WidgetSnapshot.Labels, size: CGFloat) -> some View {
|
||||
let done = todos.total > 0 ? Double(todos.total - todos.open) / Double(todos.total) : 1
|
||||
let width = size * 0.1
|
||||
return ZStack {
|
||||
ProgressRing(value: done, lineWidth: width, colors: [Color(hex: 0x4A7DDB), Color(hex: 0x6A5BE0)], trackColor: Trek.track(scheme)) { EmptyView() }
|
||||
VStack(spacing: -2) {
|
||||
Text("\(todos.open)").font(Trek.number(size * 0.3, .heavy)).foregroundStyle(Trek.ink(scheme))
|
||||
Image(systemName: "checklist").font(.system(size: size * 0.11, weight: .bold)).foregroundStyle(Trek.muted(scheme))
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
|
||||
/// What the two rings stand for, with their counts.
|
||||
private func legend(_ todos: WidgetSnapshot.Todos, _ labels: WidgetSnapshot.Labels) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
LegendPill(color: Color(hex: 0x5A6CDE), symbol: "checklist", text: labels.todos ?? "", count: "\(todos.total - todos.open)/\(todos.total)")
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
private func header(_ labels: WidgetSnapshot.Labels, _ todos: WidgetSnapshot.Todos) -> some View {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(labels.todos ?? "").trekEyebrow(scheme)
|
||||
if let trip = snapshot?.trip {
|
||||
Text(trip.title).font(.system(size: 15, weight: .heavy)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func items(_ items: [WidgetSnapshot.Todo], _ labels: WidgetSnapshot.Labels) -> some View {
|
||||
VStack(alignment: .leading, spacing: 7) {
|
||||
ForEach(Array(items.enumerated()), id: \.offset) { _, item in
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.stroke(Trek.priority(item.priority) ?? Trek.faint(scheme), lineWidth: 1.7)
|
||||
.frame(width: 14, height: 14)
|
||||
Text(item.name)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(Trek.ink(scheme))
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
if let due = RelativeDay.label(item.due, now: now, labels: labels) {
|
||||
Text(due)
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(overdue(item.due) ? Trek.danger : Trek.muted(scheme))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Capsule().fill(overdue(item.due) ? Trek.danger.opacity(0.12) : Trek.card(scheme)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func overdue(_ due: String?) -> Bool {
|
||||
guard let due, let date = WidgetSnapshot.day(due), let days = WidgetSnapshot.daysBetween(now, date) else { return false }
|
||||
return days <= 0
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func accessory(_ todos: WidgetSnapshot.Todos?, _ labels: WidgetSnapshot.Labels?) -> some View {
|
||||
let open = todos?.open ?? 0
|
||||
let total = todos?.total ?? 0
|
||||
switch family {
|
||||
case .circular:
|
||||
ZStack {
|
||||
Circle().stroke(Color.white.opacity(0.25), lineWidth: 5)
|
||||
Circle().trim(from: 0, to: total > 0 ? Double(total - open) / Double(total) : 1)
|
||||
.stroke(Color.white, style: StrokeStyle(lineWidth: 5, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
VStack(spacing: -2) {
|
||||
Image(systemName: "checklist").font(.system(size: 9, weight: .bold))
|
||||
Text("\(open)").font(Trek.number(18, .heavy))
|
||||
}
|
||||
}
|
||||
.padding(3)
|
||||
case .rectangular:
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "checklist").font(.system(size: 11, weight: .bold))
|
||||
Text("\(labels?.todos ?? "") \(open)/\(total)").font(.system(size: 12, weight: .bold))
|
||||
}
|
||||
ForEach(Array((todos?.items ?? []).prefix(2).enumerated()), id: \.offset) { _, item in
|
||||
Text("\(Image(systemName: "circle")) \(item.name)").font(.system(size: 12, weight: .medium)).lineLimit(1)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
default:
|
||||
Text("\(Image(systemName: "checklist")) \(open) \(labels?.todos ?? "")")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A ring's colour, what it counts, and how far it is.
|
||||
struct LegendPill: View {
|
||||
let color: Color
|
||||
let symbol: String
|
||||
let text: String
|
||||
let count: String
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 5) {
|
||||
Circle().fill(color).frame(width: 7, height: 7)
|
||||
Image(systemName: symbol).font(.system(size: 10, weight: .bold)).foregroundStyle(color)
|
||||
Text(text).font(.system(size: 11, weight: .bold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
|
||||
Text(count).font(Trek.number(11, .heavy)).foregroundStyle(Trek.muted(scheme))
|
||||
}
|
||||
.padding(.horizontal, 9)
|
||||
.padding(.vertical, 5)
|
||||
.background(Capsule().fill(Trek.card(scheme)))
|
||||
.overlay(Capsule().stroke(Trek.cardBorder(scheme), lineWidth: 0.6))
|
||||
}
|
||||
}
|
||||
|
||||
/// The packing list as a bar with the suitcase and the count.
|
||||
struct PackingBar: View {
|
||||
let packing: WidgetSnapshot.Packing
|
||||
let labels: WidgetSnapshot.Labels
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
let share = packing.total > 0 ? Double(packing.packed) / Double(packing.total) : 1
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: "suitcase.rolling.fill").font(.system(size: 11, weight: .bold)).foregroundStyle(Trek.confirmed)
|
||||
Text(labels.packing ?? "").font(.system(size: 11.5, weight: .bold)).foregroundStyle(Trek.ink(scheme))
|
||||
Spacer(minLength: 0)
|
||||
Text("\(packing.packed)/\(packing.total)").font(Trek.number(11.5, .heavy)).foregroundStyle(Trek.muted(scheme))
|
||||
}
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(Trek.track(scheme))
|
||||
Capsule()
|
||||
.fill(LinearGradient(colors: [Color(hex: 0x2FA37A), Color(hex: 0x7BC96F)], startPoint: .leading, endPoint: .trailing))
|
||||
.frame(width: max(6, geo.size.width * CGFloat(share)))
|
||||
}
|
||||
}
|
||||
.frame(height: 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import Foundation
|
||||
|
||||
/// The sizes the widget views draw for. Their own enum rather than WidgetKit's,
|
||||
/// so the views also render outside a widget (the preview renderer in
|
||||
/// client/ios/WidgetPreviews draws every size to a PNG on the CI's Mac).
|
||||
enum TrekFamily: String, CaseIterable {
|
||||
case small, medium, large, circular, rectangular, inline
|
||||
|
||||
var isAccessory: Bool { self == .circular || self == .rectangular || self == .inline }
|
||||
|
||||
/// The point size of the family on a 6.1" iPhone, for the renderer.
|
||||
var previewSize: CGSize {
|
||||
switch self {
|
||||
case .small: return CGSize(width: 170, height: 170)
|
||||
case .medium: return CGSize(width: 364, height: 170)
|
||||
case .large: return CGSize(width: 364, height: 382)
|
||||
case .circular: return CGSize(width: 76, height: 76)
|
||||
case .rectangular: return CGSize(width: 172, height: 76)
|
||||
case .inline: return CGSize(width: 250, height: 26)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension WidgetSnapshot {
|
||||
/// What the widget gallery shows before the app ever wrote a snapshot, and
|
||||
/// what the renderer draws: a trip to Kyoto with a full day, bookings and to-dos.
|
||||
static func sample(running: Bool, now: Date = Date()) -> WidgetSnapshot {
|
||||
let calendar = Calendar.current
|
||||
let iso: (Int) -> String = { offset in
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter.string(from: calendar.date(byAdding: .day, value: offset, to: now) ?? now)
|
||||
}
|
||||
let start = running ? -2 : 12
|
||||
var snapshot = WidgetSnapshot(
|
||||
trip: Trip(
|
||||
title: "Autumn in Kyoto", start: iso(start), end: iso(start + 8), path: "/trips/1",
|
||||
place: "Kyoto, Japan", cover: nil, dayCount: 9, placeCount: 34, memberCount: 3
|
||||
),
|
||||
today: running
|
||||
? Today(
|
||||
dayLabel: "Day 3 of 9",
|
||||
stops: [
|
||||
Stop(time: "08:30", name: "Fushimi Inari Taisha", minutes: 510, color: "#D6273B", symbol: "building.columns.fill"),
|
||||
Stop(time: "11:00", name: "Nishiki Market", minutes: 660, color: "#E8A13A", symbol: "basket.fill"),
|
||||
Stop(time: "13:30", name: "Kiyomizu-dera", minutes: 810, color: "#2FA37A", symbol: "leaf.fill"),
|
||||
Stop(time: "16:00", name: "Gion walk", minutes: 960, color: "#4A7DDB", symbol: "figure.walk"),
|
||||
Stop(time: "19:30", name: "Dinner at Pontocho", minutes: 1170, color: "#8E6CD9", symbol: "fork.knife"),
|
||||
],
|
||||
index: 3, total: 9
|
||||
)
|
||||
: nil,
|
||||
weather: Weather(temp: 18, high: 21, low: 11, main: running ? "Clear" : "Clouds", label: "Mostly sunny", place: "Kyoto", rain: 10),
|
||||
bookings: [
|
||||
Booking(title: "LH 716", kind: "flight", date: iso(running ? 6 : 12), time: "13:05", detail: "FRA → HND", confirmed: true, path: "/trips/1"),
|
||||
Booking(title: "Shinkansen Nozomi 21", kind: "train", date: iso(running ? 0 : 13), time: "09:12", detail: "Tokyo → Kyoto", confirmed: true, path: "/trips/1"),
|
||||
Booking(title: "Hotel Kanra Kyoto", kind: "hotel", date: iso(running ? 0 : 13), time: "15:00", detail: "6 nights", confirmed: true, path: "/trips/1"),
|
||||
Booking(title: "Kaiseki at Kikunoi", kind: "restaurant", date: iso(running ? 1 : 15), time: "19:00", detail: "Higashiyama", confirmed: false, path: "/trips/1"),
|
||||
Booking(title: "Tea ceremony", kind: "event", date: iso(running ? 2 : 16), time: "10:30", detail: "Camellia Flower", confirmed: true, path: "/trips/1"),
|
||||
],
|
||||
todos: Todos(
|
||||
open: 4, total: 11,
|
||||
items: [
|
||||
Todo(name: "Order JR Pass", due: iso(running ? 0 : 3), priority: 3),
|
||||
Todo(name: "Book Ghibli Museum tickets", due: iso(running ? 1 : 5), priority: 2),
|
||||
Todo(name: "Get yen in cash", due: nil, priority: 1),
|
||||
Todo(name: "Download offline maps", due: nil, priority: 0),
|
||||
],
|
||||
path: "/trips/1/todos"
|
||||
),
|
||||
packing: Packing(packed: 23, total: 31, path: "/trips/1/packing"),
|
||||
labels: Labels(
|
||||
nextTrip: "Next trip", today: "Today", noTrip: "No trip coming up. Plan the next one in TREK.",
|
||||
inDays: "in {n} days", tomorrow: "Tomorrow", nothingPlanned: "Nothing planned today",
|
||||
days: "days", dayOf: "Day {n} of {m}", bookings: "Coming up", noBookings: "No bookings ahead",
|
||||
todos: "To-dos", noTodos: "All done", packing: "Packing", packed: "packed",
|
||||
confirmed: "Confirmed", pending: "Pending", todayShort: "Today", tomorrowShort: "Tomorrow",
|
||||
now: "Now", places: "places", travellers: "travellers", allDone: "Day done", openTrek: "Open TREK",
|
||||
missing: "still to pack", countries: "countries", world: "of the world", group: "Travel group",
|
||||
poll: "Poll", votes: "votes", noMessages: "No messages yet", departure: "Departure"
|
||||
)
|
||||
)
|
||||
snapshot.packing?.bags = [
|
||||
.init(name: "Suitcase", color: "#4A7DDB", grams: 14_200, limit: 23_000, packed: 15, total: 19),
|
||||
.init(name: "Daypack", color: "#2FA37A", grams: 4_100, limit: 8_000, packed: 8, total: 12),
|
||||
]
|
||||
snapshot.packing?.missing = [
|
||||
.init(name: "Rain jacket", category: "Clothes", color: "#4A7DDB"),
|
||||
.init(name: "Power bank", category: "Tech", color: "#2FA37A"),
|
||||
.init(name: "Passport", category: "Documents", color: "#2FA37A"),
|
||||
.init(name: "Sandals", category: "Shoes", color: "#4A7DDB"),
|
||||
.init(name: "Travel adapter", category: "Tech", color: "#2FA37A"),
|
||||
.init(name: "Sunscreen", category: "Toiletries", color: nil),
|
||||
.init(name: "Earplugs", category: nil, color: nil),
|
||||
.init(name: "Book", category: nil, color: nil),
|
||||
]
|
||||
snapshot.atlas = Atlas(
|
||||
count: 23,
|
||||
codes: ["DE", "AT", "CH", "FR", "IT", "ES", "PT", "NL", "BE", "DK", "NO", "SE", "GB", "IE", "GR", "HR",
|
||||
"JP", "TH", "VN", "US", "CA", "MA", "AU"],
|
||||
recent: [
|
||||
.init(code: "JP", name: "Japan"), .init(code: "NO", name: "Norway"),
|
||||
.init(code: "PT", name: "Portugal"), .init(code: "MA", name: "Morocco"),
|
||||
],
|
||||
continents: [
|
||||
.init(name: "Europe", count: 16), .init(name: "Asia", count: 3), .init(name: "North America", count: 2),
|
||||
.init(name: "Africa", count: 1), .init(name: "Oceania", count: 1),
|
||||
],
|
||||
trips: 41, days: 318
|
||||
)
|
||||
snapshot.collab = Collab(
|
||||
members: [
|
||||
.init(name: "Maurice", initials: "MA"), .init(name: "Julien", initials: "JU"),
|
||||
.init(name: "Elmo", initials: "EL"), .init(name: "Sara", initials: "SA"),
|
||||
],
|
||||
messages: [
|
||||
.init(author: "Julien", initials: "JU", text: "Got the JR Pass vouchers, all four of us", at: nil, mine: false),
|
||||
.init(author: "Sara", initials: "SA", text: "Can we push dinner to 20:00? Train is late", at: nil, mine: false),
|
||||
.init(author: "Maurice", initials: "MA", text: "Sure, I moved the table at Pontocho", at: nil, mine: true),
|
||||
],
|
||||
poll: .init(
|
||||
question: "Day trip on Saturday?",
|
||||
options: [.init(label: "Nara", votes: 3), .init(label: "Uji", votes: 1), .init(label: "Kurama", votes: 0)],
|
||||
voters: 4
|
||||
),
|
||||
path: "/trips/1?tab=collab"
|
||||
)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/// An empty state: no trip at all.
|
||||
static var sampleEmpty: WidgetSnapshot {
|
||||
var snapshot = sample(running: false)
|
||||
snapshot.trip = nil
|
||||
snapshot.today = nil
|
||||
snapshot.weather = nil
|
||||
snapshot.bookings = []
|
||||
snapshot.todos = Todos(open: 0, total: 0, items: [], path: "/")
|
||||
snapshot.packing = nil
|
||||
snapshot.atlas = Atlas(count: 0, codes: [], recent: [], continents: [])
|
||||
snapshot.collab = nil
|
||||
return snapshot
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
/// The widgets' look, from the app's phone tokens (client/src/mobile/mobile.css):
|
||||
/// warm paper by day, near black by night, one ink colour, the status colours,
|
||||
/// and the topographic lines and route arcs that stand for travel throughout.
|
||||
enum Trek {
|
||||
static func ink(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color(hex: 0xF5F5F7) : Color(hex: 0x101013) }
|
||||
static func muted(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color(hex: 0x9C9CA3) : Color(hex: 0x68686F) }
|
||||
static func faint(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color(hex: 0x6E6E76) : Color(hex: 0x9A9AA1) }
|
||||
static func card(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color.white.opacity(0.07) : Color.white.opacity(0.62) }
|
||||
static func cardBorder(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color.white.opacity(0.09) : Color.white.opacity(0.9) }
|
||||
static func track(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color.white.opacity(0.12) : Color(hex: 0x101013).opacity(0.09) }
|
||||
|
||||
static let confirmed = Color(hex: 0x2FA37A)
|
||||
static let pending = Color(hex: 0xE8A13A)
|
||||
static let info = Color(hex: 0x4A7DDB)
|
||||
static let danger = Color(hex: 0xD6273B)
|
||||
|
||||
/// A stable number for a text (FNV-1a), unlike hashValue, which changes per launch.
|
||||
static func seed(_ text: String) -> UInt64 {
|
||||
var hash: UInt64 = 1469598103934665603
|
||||
for byte in text.utf8 { hash = (hash ^ UInt64(byte)) &* 1099511628211 }
|
||||
return hash
|
||||
}
|
||||
|
||||
/// Two colours of the trip's own, from its title: the same trip always gets
|
||||
/// the same sky, and two trips side by side rarely the same one.
|
||||
static func tripHues(_ title: String) -> (Color, Color) {
|
||||
let base = Double(seed(title) % 360) / 360
|
||||
let first = Color(hue: base, saturation: 0.62, brightness: 0.78)
|
||||
let second = Color(hue: (base + 0.11).truncatingRemainder(dividingBy: 1), saturation: 0.7, brightness: 0.42)
|
||||
return (first, second)
|
||||
}
|
||||
|
||||
static func number(_ size: CGFloat, _ weight: Font.Weight = .bold) -> Font {
|
||||
.system(size: size, weight: weight, design: .rounded).monospacedDigit()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private struct Eyebrow: ViewModifier {
|
||||
let scheme: ColorScheme
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.font(.system(size: 10.5, weight: .bold))
|
||||
.tracking(0.6)
|
||||
.textCase(.uppercase)
|
||||
.foregroundStyle(Trek.muted(scheme))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func trekEyebrow(_ scheme: ColorScheme) -> some View { modifier(Eyebrow(scheme: scheme)) }
|
||||
}
|
||||
|
||||
extension Color {
|
||||
init(hex: UInt32, opacity: Double = 1) {
|
||||
self.init(
|
||||
.sRGB,
|
||||
red: Double((hex >> 16) & 0xFF) / 255,
|
||||
green: Double((hex >> 8) & 0xFF) / 255,
|
||||
blue: Double(hex & 0xFF) / 255,
|
||||
opacity: opacity
|
||||
)
|
||||
}
|
||||
|
||||
/// "#rrggbb" or "rrggbb"; nil for anything else.
|
||||
init?(css: String?) {
|
||||
guard let css else { return nil }
|
||||
let hex = css.hasPrefix("#") ? String(css.dropFirst()) : css
|
||||
guard hex.count == 6, let value = UInt32(hex, radix: 16) else { return nil }
|
||||
self.init(hex: value)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Backgrounds
|
||||
|
||||
/// The paper the widgets sit on: the app's screen gradient, with a faint
|
||||
/// contour map over it so even an empty widget reads as TREK.
|
||||
struct TrekPaper: View {
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
var contours = true
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if scheme == .dark {
|
||||
RadialGradient(
|
||||
colors: [Color(hex: 0x1B1B21), Color(hex: 0x0C0C0F), Color(hex: 0x070709)],
|
||||
center: UnitPoint(x: 0.5, y: -0.1), startRadius: 0, endRadius: 360
|
||||
)
|
||||
} else {
|
||||
RadialGradient(
|
||||
colors: [Color(hex: 0xFDFCF9), Color(hex: 0xF1EFEA), Color(hex: 0xE7E5DF)],
|
||||
center: UnitPoint(x: 0.5, y: -0.1), startRadius: 0, endRadius: 360
|
||||
)
|
||||
}
|
||||
if contours {
|
||||
ContourLines(seed: 7, rings: 9)
|
||||
.stroke(Trek.ink(scheme).opacity(scheme == .dark ? 0.05 : 0.045), lineWidth: 0.8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A trip's own backdrop: its cover photo when the app sent one, else its two
|
||||
/// colours as a sky with contour lines, both darkened towards the text.
|
||||
struct TripBackdrop: View {
|
||||
let trip: WidgetSnapshot.Trip?
|
||||
var shade: Double = 0.62
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if let image = coverImage {
|
||||
image.resizable().scaledToFill()
|
||||
} else {
|
||||
let (first, second) = Trek.tripHues(trip?.title ?? "TREK")
|
||||
LinearGradient(colors: [first, second], startPoint: .topLeading, endPoint: .bottomTrailing)
|
||||
RadialGradient(colors: [Color.white.opacity(0.28), .clear], center: UnitPoint(x: 0.85, y: 0.05), startRadius: 0, endRadius: 220)
|
||||
ContourLines(seed: Trek.seed(trip?.title ?? "") % 997, rings: 11)
|
||||
.stroke(Color.white.opacity(0.14), lineWidth: 0.9)
|
||||
}
|
||||
LinearGradient(
|
||||
colors: [Color.black.opacity(shade * 0.35), Color.black.opacity(0.05), Color.black.opacity(shade)],
|
||||
startPoint: .top, endPoint: .bottom
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var coverImage: Image? {
|
||||
guard let base64 = trip?.cover, let data = Data(base64Encoded: base64) else { return nil }
|
||||
#if canImport(UIKit)
|
||||
guard let image = UIImage(data: data) else { return nil }
|
||||
return Image(uiImage: image)
|
||||
#elseif canImport(AppKit)
|
||||
guard let image = NSImage(data: data) else { return nil }
|
||||
return Image(nsImage: image)
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// Wobbly closed rings around an off-centre summit: a contour map in a few
|
||||
/// lines, the same for the same seed.
|
||||
struct ContourLines: Shape {
|
||||
var seed: UInt64
|
||||
var rings: Int
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
let phaseA = CGFloat(seed % 13) * 0.7
|
||||
let phaseB = CGFloat(seed % 7) * 1.3
|
||||
let offset: CGFloat = 0.62 + CGFloat(seed % 5) * 0.04
|
||||
let centerX: CGFloat = rect.minX + rect.width * offset
|
||||
let centerY: CGFloat = rect.minY + rect.height * 0.32
|
||||
let maxRadius: CGFloat = max(rect.width, rect.height) * 1.05
|
||||
let count = max(rings, 1)
|
||||
let steps = 72
|
||||
for ring in 1...count {
|
||||
let base: CGFloat = maxRadius * CGFloat(ring) / CGFloat(count)
|
||||
let twist: CGFloat = CGFloat(ring) * 0.35
|
||||
for step in 0...steps {
|
||||
let angle: CGFloat = CGFloat(step) / CGFloat(steps) * 2 * .pi
|
||||
let first: CGFloat = 0.09 * sin(angle * 3 + phaseA + twist)
|
||||
let second: CGFloat = 0.05 * sin(angle * 5 + phaseB)
|
||||
let radius: CGFloat = base * (1 + first + second)
|
||||
let point = CGPoint(x: centerX + cos(angle) * radius * 1.15, y: centerY + sin(angle) * radius * 0.82)
|
||||
if step == 0 { path.move(to: point) } else { path.addLine(to: point) }
|
||||
}
|
||||
path.closeSubpath()
|
||||
}
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
/// A dotted flight path from home to the trip, with the plane on it as far as
|
||||
/// the countdown has come.
|
||||
struct RouteArc: View {
|
||||
/// 0 at home, 1 arrived.
|
||||
let progress: Double
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let start = CGPoint(x: 6, y: geo.size.height - 6)
|
||||
let end = CGPoint(x: geo.size.width - 6, y: geo.size.height - 6)
|
||||
let control = CGPoint(x: geo.size.width / 2, y: geo.size.height * -0.55)
|
||||
ZStack {
|
||||
Path { path in
|
||||
path.move(to: start)
|
||||
path.addQuadCurve(to: end, control: control)
|
||||
}
|
||||
.stroke(color.opacity(0.45), style: StrokeStyle(lineWidth: 1.4, lineCap: .round, dash: [1.5, 4.5]))
|
||||
Path { path in
|
||||
path.move(to: start)
|
||||
path.addQuadCurve(to: end, control: control)
|
||||
}
|
||||
.trim(from: 0, to: progress)
|
||||
.stroke(color, style: StrokeStyle(lineWidth: 1.8, lineCap: .round))
|
||||
Circle().fill(color).frame(width: 6, height: 6).position(start)
|
||||
Circle().stroke(color, lineWidth: 1.6).frame(width: 9, height: 9).position(end)
|
||||
let point = quadPoint(start, control, end, progress)
|
||||
let tangent = quadTangent(start, control, end, progress)
|
||||
Image(systemName: "airplane")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(color)
|
||||
.rotationEffect(.radians(Double(atan2(tangent.y, tangent.x))))
|
||||
.position(point)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func quadPoint(_ a: CGPoint, _ c: CGPoint, _ b: CGPoint, _ progress: Double) -> CGPoint {
|
||||
let t = CGFloat(progress)
|
||||
let u: CGFloat = 1 - t
|
||||
let x: CGFloat = u * u * a.x + 2 * u * t * c.x + t * t * b.x
|
||||
let y: CGFloat = u * u * a.y + 2 * u * t * c.y + t * t * b.y
|
||||
return CGPoint(x: x, y: y)
|
||||
}
|
||||
|
||||
private func quadTangent(_ a: CGPoint, _ c: CGPoint, _ b: CGPoint, _ progress: Double) -> CGPoint {
|
||||
let t = CGFloat(progress)
|
||||
let x: CGFloat = 2 * (1 - t) * (c.x - a.x) + 2 * t * (b.x - c.x)
|
||||
let y: CGFloat = 2 * (1 - t) * (c.y - a.y) + 2 * t * (b.y - c.y)
|
||||
return CGPoint(x: x, y: y)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Components
|
||||
|
||||
private struct StaticPreviewKey: EnvironmentKey {
|
||||
static let defaultValue = false
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
/// Set by the preview renderer, which cannot draw a Link.
|
||||
var trekStaticPreview: Bool {
|
||||
get { self[StaticPreviewKey.self] }
|
||||
set { self[StaticPreviewKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
/// A row that opens its own place in the app, or just the row in a preview.
|
||||
struct RowLink<Label: View>: View {
|
||||
let path: String
|
||||
@ViewBuilder var label: () -> Label
|
||||
@Environment(\.trekStaticPreview) private var staticPreview
|
||||
|
||||
var body: some View {
|
||||
if staticPreview {
|
||||
label()
|
||||
} else if let url = WidgetSnapshot.link(path) {
|
||||
Link(destination: url) { label() }
|
||||
} else {
|
||||
label()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A ring that fills with progress, a soft track under it.
|
||||
struct ProgressRing<Label: View>: View {
|
||||
let value: Double
|
||||
var lineWidth: CGFloat = 6
|
||||
var colors: [Color]
|
||||
var trackColor: Color
|
||||
@ViewBuilder var label: () -> Label
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle().stroke(trackColor, lineWidth: lineWidth)
|
||||
Circle()
|
||||
.trim(from: 0, to: max(0.001, min(1, value)))
|
||||
.stroke(
|
||||
AngularGradient(colors: colors + [colors.first ?? .white], center: .center, startAngle: .degrees(-90), endAngle: .degrees(270)),
|
||||
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
label()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The trip's days as a row of segments, those behind filled, today glowing.
|
||||
struct DaySegments: View {
|
||||
let index: Int
|
||||
let total: Int
|
||||
let color: Color
|
||||
let track: Color
|
||||
|
||||
var body: some View {
|
||||
let count = min(max(total, 1), 21)
|
||||
let current = total > 21 ? Int((Double(index) / Double(total) * 21).rounded(.up)) : index
|
||||
HStack(spacing: count > 12 ? 2 : 3) {
|
||||
ForEach(1...count, id: \.self) { day in
|
||||
Capsule()
|
||||
.fill(day < current ? color : day == current ? color : track)
|
||||
.opacity(day < current ? 0.55 : 1)
|
||||
.shadow(color: day == current ? color.opacity(0.7) : .clear, radius: 3)
|
||||
}
|
||||
}
|
||||
.frame(height: 4)
|
||||
}
|
||||
}
|
||||
|
||||
/// Today's weather in a pill: the multicolour symbol and the temperature.
|
||||
struct WeatherChip: View {
|
||||
let weather: WidgetSnapshot.Weather
|
||||
var onPhoto = false
|
||||
var compact = false
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: Trek.weatherSymbol(weather.main))
|
||||
.symbolRenderingMode(.multicolor)
|
||||
.font(.system(size: compact ? 12 : 14, weight: .semibold))
|
||||
Text("\(weather.temp)°")
|
||||
.font(Trek.number(compact ? 12 : 14, .semibold))
|
||||
.foregroundStyle(onPhoto ? .white : Trek.ink(scheme))
|
||||
}
|
||||
.padding(.horizontal, compact ? 7 : 9)
|
||||
.padding(.vertical, compact ? 3 : 4)
|
||||
.background(
|
||||
Capsule().fill(onPhoto ? Color.black.opacity(0.28) : Trek.card(scheme))
|
||||
)
|
||||
.overlay(Capsule().stroke(onPhoto ? Color.white.opacity(0.22) : Trek.cardBorder(scheme), lineWidth: 0.6))
|
||||
}
|
||||
}
|
||||
|
||||
extension Trek {
|
||||
static func weatherSymbol(_ main: String) -> String {
|
||||
switch main.lowercased() {
|
||||
case "clear": return "sun.max.fill"
|
||||
case "clouds": return "cloud.sun.fill"
|
||||
case "rain", "drizzle": return "cloud.rain.fill"
|
||||
case "thunderstorm": return "cloud.bolt.rain.fill"
|
||||
case "snow": return "cloud.snow.fill"
|
||||
case "mist", "fog", "haze": return "cloud.fog.fill"
|
||||
default: return "cloud.fill"
|
||||
}
|
||||
}
|
||||
|
||||
/// Symbol and colour of a booking's kind.
|
||||
static func booking(_ kind: String) -> (symbol: String, color: Color) {
|
||||
switch kind {
|
||||
case "flight": return ("airplane", Color(hex: 0x4A7DDB))
|
||||
case "train": return ("tram.fill", Color(hex: 0x2FA37A))
|
||||
case "bus": return ("bus.fill", Color(hex: 0x2BA6A6))
|
||||
case "car": return ("car.fill", Color(hex: 0xD6673B))
|
||||
case "cruise", "ferry": return ("ferry.fill", Color(hex: 0x2E8BC0))
|
||||
case "hotel": return ("bed.double.fill", Color(hex: 0x8E6CD9))
|
||||
case "restaurant": return ("fork.knife", Color(hex: 0xE8A13A))
|
||||
case "event": return ("ticket.fill", Color(hex: 0xD6408B))
|
||||
case "tour": return ("figure.hiking", Color(hex: 0x4E9F3D))
|
||||
default: return ("bookmark.fill", Color(hex: 0x8A8A93))
|
||||
}
|
||||
}
|
||||
|
||||
static func priority(_ value: Int) -> Color? {
|
||||
switch value {
|
||||
case 3: return danger
|
||||
case 2: return pending
|
||||
case 1: return info
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A booking's kind as a tinted rounded square.
|
||||
struct KindBadge: View {
|
||||
let kind: String
|
||||
var size: CGFloat = 30
|
||||
|
||||
var body: some View {
|
||||
let look = Trek.booking(kind)
|
||||
RoundedRectangle(cornerRadius: size * 0.32, style: .continuous)
|
||||
.fill(LinearGradient(colors: [look.color, look.color.opacity(0.78)], startPoint: .top, endPoint: .bottom))
|
||||
.frame(width: size, height: size)
|
||||
.overlay(
|
||||
Image(systemName: look.symbol)
|
||||
.font(.system(size: size * 0.46, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
)
|
||||
.shadow(color: look.color.opacity(0.35), radius: 4, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
/// "Today", "Tomorrow" or the short weekday and date of a yyyy-MM-dd.
|
||||
enum RelativeDay {
|
||||
static func label(_ iso: String?, now: Date, labels: WidgetSnapshot.Labels) -> String? {
|
||||
guard let iso, let date = WidgetSnapshot.day(iso), let days = WidgetSnapshot.daysBetween(now, date) else { return nil }
|
||||
if days == 0 { return labels.todayShort ?? labels.today }
|
||||
if days == 1 { return labels.tomorrowShort ?? labels.tomorrow }
|
||||
let formatter = DateFormatter()
|
||||
formatter.setLocalizedDateFormatFromTemplate(days < 7 ? "EEE" : "EEEdMMM")
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
static func range(_ start: String, _ end: String) -> String? {
|
||||
guard let from = WidgetSnapshot.day(start), let to = WidgetSnapshot.day(end) else { return nil }
|
||||
let formatter = DateIntervalFormatter()
|
||||
formatter.dateTemplate = "dMMM"
|
||||
return formatter.string(from: from, to: to)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The trip widget: a countdown on the trip's own cover before it starts, the
|
||||
/// day and what comes next while it runs, an invitation when there is none.
|
||||
struct TripContent: View {
|
||||
let snapshot: WidgetSnapshot?
|
||||
let family: TrekFamily
|
||||
let now: Date
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
private var labels: WidgetSnapshot.Labels? { snapshot?.labels }
|
||||
|
||||
var body: some View {
|
||||
if family.isAccessory {
|
||||
TripAccessory(snapshot: snapshot, family: family, now: now)
|
||||
} else if let snapshot, let trip = snapshot.trip {
|
||||
if let today = snapshot.today {
|
||||
running(snapshot, trip, today)
|
||||
} else {
|
||||
upcoming(snapshot, trip)
|
||||
}
|
||||
} else {
|
||||
EmptyTrip(text: snapshot?.labels.noTrip ?? "", family: family)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Before the trip
|
||||
|
||||
@ViewBuilder
|
||||
private func upcoming(_ snapshot: WidgetSnapshot, _ trip: WidgetSnapshot.Trip) -> some View {
|
||||
let days = max(snapshot.daysUntilStart(from: now) ?? 0, 0)
|
||||
// The ring and the plane cover the last 60 days before departure.
|
||||
let progress = 1 - min(Double(days), 60) / 60
|
||||
switch family {
|
||||
case .small:
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
PlaneMark()
|
||||
Spacer(minLength: 0)
|
||||
if let weather = snapshot.weather { WeatherChip(weather: weather, onPhoto: true, compact: true) }
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
countdown(days, snapshot.labels, size: 46)
|
||||
Text(trip.title)
|
||||
.font(.system(size: 15, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.85)
|
||||
.padding(.top, 1)
|
||||
if let range = RelativeDay.range(trip.start, trip.end) {
|
||||
Text(range).font(.system(size: 11, weight: .medium)).foregroundStyle(.white.opacity(0.75)).lineLimit(1)
|
||||
}
|
||||
}
|
||||
case .medium:
|
||||
HStack(spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(snapshot.labels.nextTrip).trekEyebrowOnPhoto()
|
||||
Text(trip.title)
|
||||
.font(.system(size: 20, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.8)
|
||||
.padding(.top, 3)
|
||||
if let place = trip.place {
|
||||
Label(place, systemImage: "location.fill")
|
||||
.font(.system(size: 11.5, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.82))
|
||||
.lineLimit(1)
|
||||
.padding(.top, 3)
|
||||
}
|
||||
Spacer(minLength: 6)
|
||||
RouteArc(progress: progress, color: .white)
|
||||
.frame(height: 26)
|
||||
.padding(.bottom, 8)
|
||||
HStack(spacing: 6) {
|
||||
if let range = RelativeDay.range(trip.start, trip.end) { PhotoPill(symbol: "calendar", text: range) }
|
||||
if let weather = snapshot.weather { WeatherChip(weather: weather, onPhoto: true, compact: true) }
|
||||
}
|
||||
}
|
||||
ProgressRing(value: progress, lineWidth: 7, colors: [.white.opacity(0.75), .white], trackColor: .white.opacity(0.2)) {
|
||||
VStack(spacing: -2) {
|
||||
Text("\(days)").font(Trek.number(days > 99 ? 26 : 32, .heavy)).foregroundStyle(.white)
|
||||
Text(snapshot.labels.days ?? "").font(.system(size: 10, weight: .bold)).foregroundStyle(.white.opacity(0.75)).textCase(.uppercase)
|
||||
}
|
||||
}
|
||||
.frame(width: 96, height: 96)
|
||||
}
|
||||
default:
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
Text(snapshot.labels.nextTrip).trekEyebrowOnPhoto()
|
||||
Spacer(minLength: 0)
|
||||
if let weather = snapshot.weather { WeatherChip(weather: weather, onPhoto: true) }
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
HStack(alignment: .bottom, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(trip.title)
|
||||
.font(.system(size: 26, weight: .heavy))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.75)
|
||||
if let place = trip.place {
|
||||
Label(place, systemImage: "location.fill")
|
||||
.font(.system(size: 12.5, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
countdown(days, snapshot.labels, size: 52)
|
||||
}
|
||||
RouteArc(progress: progress, color: .white)
|
||||
.frame(height: 34)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 12)
|
||||
TripFacts(snapshot: snapshot, onPhoto: true)
|
||||
if let booking = snapshot.bookings?.first {
|
||||
NextBookingStrip(booking: booking, labels: snapshot.labels, now: now)
|
||||
.padding(.top, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func countdown(_ days: Int, _ labels: WidgetSnapshot.Labels, size: CGFloat) -> some View {
|
||||
Group {
|
||||
if days == 0 {
|
||||
Text(labels.todayShort ?? labels.today)
|
||||
.font(.system(size: size * 0.62, weight: .heavy, design: .rounded))
|
||||
} else if days == 1 {
|
||||
Text(labels.tomorrow)
|
||||
.font(.system(size: size * 0.52, weight: .heavy, design: .rounded))
|
||||
.minimumScaleFactor(0.6)
|
||||
} else {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text("\(days)").font(Trek.number(size, .heavy))
|
||||
Text(labels.days ?? "").font(.system(size: size * 0.28, weight: .bold)).opacity(0.8)
|
||||
}
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.shadow(color: .black.opacity(0.25), radius: 6, y: 2)
|
||||
}
|
||||
|
||||
// MARK: While travelling
|
||||
|
||||
@ViewBuilder
|
||||
private func running(_ snapshot: WidgetSnapshot, _ trip: WidgetSnapshot.Trip, _ today: WidgetSnapshot.Today) -> some View {
|
||||
let nowMinutes = Calendar.current.component(.hour, from: now) * 60 + Calendar.current.component(.minute, from: now)
|
||||
let next = today.stops.first { ($0.minutes ?? -1) >= nowMinutes } ?? today.stops.last
|
||||
let index = today.index ?? 1
|
||||
let total = today.total ?? snapshot.tripLength ?? 1
|
||||
switch family {
|
||||
case .small:
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
Text(today.dayLabel).trekEyebrowOnPhoto()
|
||||
Spacer(minLength: 0)
|
||||
if let weather = snapshot.weather { WeatherChip(weather: weather, onPhoto: true, compact: true) }
|
||||
}
|
||||
DaySegments(index: index, total: total, color: .white, track: .white.opacity(0.25)).padding(.top, 7)
|
||||
Spacer(minLength: 0)
|
||||
if let next {
|
||||
Text(next.time ?? snapshot.labels.today)
|
||||
.font(Trek.number(28, .heavy))
|
||||
.foregroundStyle(.white)
|
||||
Text(next.name)
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(2)
|
||||
} else {
|
||||
Text(snapshot.labels.nothingPlanned).font(.system(size: 14, weight: .bold)).foregroundStyle(.white).lineLimit(3)
|
||||
}
|
||||
Text(trip.title).font(.system(size: 11, weight: .medium)).foregroundStyle(.white.opacity(0.7)).lineLimit(1).padding(.top, 2)
|
||||
}
|
||||
case .medium:
|
||||
HStack(spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(trip.title).trekEyebrowOnPhoto()
|
||||
Text(today.dayLabel)
|
||||
.font(.system(size: 21, weight: .heavy))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
.padding(.top, 3)
|
||||
DaySegments(index: index, total: total, color: .white, track: .white.opacity(0.25)).padding(.top, 8)
|
||||
Spacer(minLength: 0)
|
||||
if let weather = snapshot.weather {
|
||||
HStack(spacing: 6) {
|
||||
WeatherChip(weather: weather, onPhoto: true)
|
||||
if let high = weather.high, let low = weather.low {
|
||||
Text("\(high)° / \(low)°").font(Trek.number(11.5, .semibold)).foregroundStyle(.white.opacity(0.8))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
GlassPanel {
|
||||
StopList(stops: today.stops, nowMinutes: nowMinutes, limit: 3, onPhoto: true, nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now)
|
||||
}
|
||||
.frame(width: 176)
|
||||
}
|
||||
default:
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(trip.title).trekEyebrowOnPhoto()
|
||||
Text(today.dayLabel).font(.system(size: 24, weight: .heavy)).foregroundStyle(.white).lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if let weather = snapshot.weather { BigWeather(weather: weather) }
|
||||
}
|
||||
DaySegments(index: index, total: total, color: .white, track: .white.opacity(0.25)).padding(.top, 10)
|
||||
GlassPanel {
|
||||
StopList(stops: today.stops, nowMinutes: nowMinutes, limit: 6, onPhoto: true, nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now)
|
||||
}
|
||||
.padding(.top, 14)
|
||||
Spacer(minLength: 8)
|
||||
if let booking = snapshot.bookings?.first {
|
||||
NextBookingStrip(booking: booking, labels: snapshot.labels, now: now)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pieces
|
||||
|
||||
private extension Text {
|
||||
func trekEyebrowOnPhoto() -> some View {
|
||||
self.font(.system(size: 10.5, weight: .bold))
|
||||
.tracking(0.7)
|
||||
.textCase(.uppercase)
|
||||
.foregroundStyle(.white.opacity(0.78))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
/// The plane in a frosted circle that marks a trip widget.
|
||||
struct PlaneMark: View {
|
||||
var body: some View {
|
||||
Circle()
|
||||
.fill(.white.opacity(0.22))
|
||||
.overlay(Circle().stroke(.white.opacity(0.35), lineWidth: 0.6))
|
||||
.frame(width: 26, height: 26)
|
||||
.overlay(Image(systemName: "airplane").font(.system(size: 12, weight: .bold)).foregroundStyle(.white).rotationEffect(.degrees(-35)))
|
||||
}
|
||||
}
|
||||
|
||||
struct PhotoPill: View {
|
||||
let symbol: String
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: symbol).font(.system(size: 10, weight: .bold))
|
||||
Text(text).font(.system(size: 11, weight: .semibold)).lineLimit(1)
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Capsule().fill(Color.black.opacity(0.28)))
|
||||
.overlay(Capsule().stroke(Color.white.opacity(0.22), lineWidth: 0.6))
|
||||
}
|
||||
}
|
||||
|
||||
/// Frosted glass over the photo, for lists that need a calm ground.
|
||||
struct GlassPanel<Content: View>: View {
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
content()
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(Color.black.opacity(0.32))
|
||||
)
|
||||
.overlay(RoundedRectangle(cornerRadius: 16, style: .continuous).stroke(Color.white.opacity(0.16), lineWidth: 0.7))
|
||||
}
|
||||
}
|
||||
|
||||
/// Days, places and travellers as three small columns.
|
||||
struct TripFacts: View {
|
||||
let snapshot: WidgetSnapshot
|
||||
var onPhoto = false
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
let labels = snapshot.labels
|
||||
HStack(spacing: 0) {
|
||||
if let length = snapshot.tripLength {
|
||||
fact("\(length)", labels.days ?? "", "calendar")
|
||||
}
|
||||
if let places = snapshot.trip?.placeCount, places > 0 {
|
||||
divider
|
||||
fact("\(places)", labels.places ?? "", "mappin")
|
||||
}
|
||||
if let members = snapshot.trip?.memberCount, members > 0 {
|
||||
divider
|
||||
fact("\(members)", labels.travellers ?? "", "person.2.fill")
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(onPhoto ? Color.black.opacity(0.28) : Trek.card(scheme)))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(onPhoto ? Color.white.opacity(0.18) : Trek.cardBorder(scheme), lineWidth: 0.7))
|
||||
}
|
||||
|
||||
private var divider: some View {
|
||||
Rectangle().fill(onPhoto ? Color.white.opacity(0.2) : Trek.track(scheme)).frame(width: 0.7, height: 26)
|
||||
}
|
||||
|
||||
private func fact(_ value: String, _ label: String, _ symbol: String) -> some View {
|
||||
VStack(spacing: 1) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: symbol).font(.system(size: 10, weight: .bold)).opacity(0.75)
|
||||
Text(value).font(Trek.number(17, .heavy))
|
||||
}
|
||||
Text(label).font(.system(size: 9.5, weight: .bold)).textCase(.uppercase).tracking(0.4).opacity(0.7).lineLimit(1)
|
||||
}
|
||||
.foregroundStyle(onPhoto ? .white : Trek.ink(scheme))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
/// The first booking ahead, as a strip under the trip.
|
||||
struct NextBookingStrip: View {
|
||||
let booking: WidgetSnapshot.Booking
|
||||
let labels: WidgetSnapshot.Labels
|
||||
let now: Date
|
||||
var onPhoto = true
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 9) {
|
||||
KindBadge(kind: booking.kind, size: 28)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(booking.title).font(.system(size: 13, weight: .bold)).lineLimit(1)
|
||||
if let detail = booking.detail {
|
||||
Text(detail).font(.system(size: 11, weight: .medium)).opacity(0.75).lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 4)
|
||||
VStack(alignment: .trailing, spacing: 1) {
|
||||
if let day = RelativeDay.label(booking.date, now: now, labels: labels) {
|
||||
Text(day).font(.system(size: 11, weight: .bold)).opacity(0.8)
|
||||
}
|
||||
if let time = booking.time { Text(time).font(Trek.number(14, .heavy)) }
|
||||
}
|
||||
}
|
||||
.foregroundStyle(onPhoto ? .white : Trek.ink(scheme))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(onPhoto ? Color.black.opacity(0.3) : Trek.card(scheme)))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(onPhoto ? Color.white.opacity(0.16) : Trek.cardBorder(scheme), lineWidth: 0.7))
|
||||
}
|
||||
}
|
||||
|
||||
/// The weather as the large widget shows it: symbol, temperature, high and low.
|
||||
struct BigWeather: View {
|
||||
let weather: WidgetSnapshot.Weather
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: Trek.weatherSymbol(weather.main))
|
||||
.symbolRenderingMode(.multicolor)
|
||||
.font(.system(size: 26))
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("\(weather.temp)°").font(Trek.number(22, .heavy))
|
||||
if let high = weather.high, let low = weather.low {
|
||||
Text("\(high)° / \(low)°").font(Trek.number(10.5, .semibold)).opacity(0.8)
|
||||
}
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
|
||||
/// The day's stops as a timeline: a dot in each stop's colour on a thread, the
|
||||
/// ones behind faded, a now marker between the past and the next.
|
||||
struct StopList: View {
|
||||
let stops: [WidgetSnapshot.Stop]
|
||||
let nowMinutes: Int
|
||||
let limit: Int
|
||||
var onPhoto = false
|
||||
let nothing: String
|
||||
var nowLabel: String?
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
if stops.isEmpty {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "sparkles").font(.system(size: 15, weight: .semibold))
|
||||
Text(nothing).font(.system(size: 13, weight: .semibold)).lineLimit(2)
|
||||
}
|
||||
.foregroundStyle(ink.opacity(0.85))
|
||||
.frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
|
||||
} else {
|
||||
let window = visible
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(Array(window.enumerated()), id: \.offset) { offset, stop in
|
||||
let past = (stop.minutes ?? Int.max) < nowMinutes
|
||||
let isNext = !past && (offset == 0 || (window[offset - 1].minutes ?? Int.max) < nowMinutes)
|
||||
if isNext && offset > 0, let nowLabel { nowLine(nowLabel) }
|
||||
row(stop, past: past, isNext: isNext, last: offset == window.count - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The stops around now: the last one behind, then the ones ahead.
|
||||
private var visible: [WidgetSnapshot.Stop] {
|
||||
let nextIndex = stops.firstIndex { ($0.minutes ?? Int.max) >= nowMinutes } ?? max(stops.count - limit, 0)
|
||||
let first = max(0, min(nextIndex - 1, stops.count - limit))
|
||||
return Array(stops[first..<min(stops.count, first + limit)])
|
||||
}
|
||||
|
||||
private var ink: Color { onPhoto ? .white : Trek.ink(scheme) }
|
||||
|
||||
private func row(_ stop: WidgetSnapshot.Stop, past: Bool, isNext: Bool, last: Bool) -> some View {
|
||||
let color = Color(css: stop.color) ?? (onPhoto ? .white : Trek.info)
|
||||
return HStack(alignment: .top, spacing: 8) {
|
||||
Text(stop.time ?? "–")
|
||||
.font(Trek.number(11.5, isNext ? .heavy : .semibold))
|
||||
.frame(width: 38, alignment: .leading)
|
||||
.padding(.top, 1)
|
||||
VStack(spacing: 0) {
|
||||
ZStack {
|
||||
Circle().fill(color).frame(width: isNext ? 11 : 8, height: isNext ? 11 : 8)
|
||||
if isNext { Circle().stroke(color.opacity(0.4), lineWidth: 3).frame(width: 15, height: 15) }
|
||||
}
|
||||
.frame(width: 15, height: 15)
|
||||
if !last {
|
||||
Rectangle().fill(ink.opacity(0.22)).frame(width: 1.2).frame(maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
.frame(width: 15)
|
||||
HStack(spacing: 5) {
|
||||
if let symbol = stop.symbol {
|
||||
Image(systemName: symbol).font(.system(size: 10, weight: .semibold)).foregroundStyle(color)
|
||||
}
|
||||
Text(stop.name)
|
||||
.font(.system(size: 12.5, weight: isNext ? .bold : .semibold))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.top, 0.5)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.foregroundStyle(ink)
|
||||
.opacity(past ? 0.5 : 1)
|
||||
.frame(height: 24, alignment: .top)
|
||||
}
|
||||
|
||||
private func nowLine(_ label: String) -> some View {
|
||||
HStack(spacing: 5) {
|
||||
Text(label.uppercased()).font(.system(size: 8.5, weight: .heavy)).tracking(0.5).foregroundStyle(Trek.danger)
|
||||
.frame(width: 38, alignment: .leading)
|
||||
Circle().fill(Trek.danger).frame(width: 5, height: 5).frame(width: 15)
|
||||
Rectangle().fill(Trek.danger).frame(height: 1.2)
|
||||
}
|
||||
.frame(height: 9)
|
||||
.padding(.bottom, 3)
|
||||
}
|
||||
}
|
||||
|
||||
/// No trip: the paper, the contour lines, and an invitation.
|
||||
struct EmptyTrip: View {
|
||||
let text: String
|
||||
let family: TrekFamily
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "map.fill").font(.system(size: 13, weight: .bold))
|
||||
Text("TREK").font(.system(size: 15, weight: .heavy)).tracking(1.2)
|
||||
}
|
||||
.foregroundStyle(Trek.ink(scheme))
|
||||
Spacer(minLength: 0)
|
||||
Image(systemName: "globe.europe.africa.fill")
|
||||
.font(.system(size: family == .small ? 30 : 38, weight: .light))
|
||||
.foregroundStyle(Trek.muted(scheme).opacity(0.6))
|
||||
Text(text)
|
||||
.font(.system(size: family == .small ? 12.5 : 14, weight: .semibold))
|
||||
.foregroundStyle(Trek.muted(scheme))
|
||||
.lineLimit(3)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
/// The trip on the lock screen: a ring with the days left or the day of the
|
||||
/// trip, a line with the next stop, a word in the status row.
|
||||
struct TripAccessory: View {
|
||||
let snapshot: WidgetSnapshot?
|
||||
let family: TrekFamily
|
||||
let now: Date
|
||||
|
||||
var body: some View {
|
||||
let trip = snapshot?.trip
|
||||
let today = snapshot?.today
|
||||
let days = max(snapshot?.daysUntilStart(from: now) ?? 0, 0)
|
||||
let nowMinutes = Calendar.current.component(.hour, from: now) * 60 + Calendar.current.component(.minute, from: now)
|
||||
let next = today?.stops.first { ($0.minutes ?? -1) >= nowMinutes }
|
||||
switch family {
|
||||
case .circular:
|
||||
ZStack {
|
||||
Circle().stroke(Color.white.opacity(0.25), lineWidth: 5)
|
||||
if let today, let index = today.index, let total = today.total ?? snapshot?.tripLength {
|
||||
Circle().trim(from: 0, to: Double(index) / Double(max(total, 1)))
|
||||
.stroke(Color.white, style: StrokeStyle(lineWidth: 5, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
VStack(spacing: -3) {
|
||||
Text("\(index)").font(Trek.number(20, .heavy))
|
||||
Text("/\(total)").font(Trek.number(9, .bold)).opacity(0.75)
|
||||
}
|
||||
} else if trip != nil {
|
||||
Circle().trim(from: 0, to: 1 - min(Double(days), 60) / 60)
|
||||
.stroke(Color.white, style: StrokeStyle(lineWidth: 5, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
VStack(spacing: -3) {
|
||||
Image(systemName: "airplane").font(.system(size: 9, weight: .bold))
|
||||
Text("\(days)").font(Trek.number(days > 99 ? 15 : 19, .heavy))
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "map.fill").font(.system(size: 20))
|
||||
}
|
||||
}
|
||||
.padding(3)
|
||||
case .rectangular:
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: today == nil ? "airplane" : "mappin.circle.fill").font(.system(size: 11, weight: .bold))
|
||||
Text(trip?.title ?? "TREK").font(.system(size: 13, weight: .bold)).lineLimit(1)
|
||||
}
|
||||
if let today {
|
||||
Text(next.map { "\($0.time ?? "") \($0.name)" } ?? snapshot?.labels.allDone ?? today.dayLabel)
|
||||
.font(.system(size: 13, weight: .semibold)).lineLimit(1)
|
||||
Text(today.dayLabel).font(.system(size: 11, weight: .medium)).opacity(0.75).lineLimit(1)
|
||||
} else if let snapshot, trip != nil {
|
||||
Text(days == 1 ? snapshot.labels.tomorrow : snapshot.labels.inDays.replacingOccurrences(of: "{n}", with: "\(days)"))
|
||||
.font(.system(size: 15, weight: .heavy)).lineLimit(1)
|
||||
if let range = trip.flatMap({ RelativeDay.range($0.start, $0.end) }) {
|
||||
Text(range).font(.system(size: 11, weight: .medium)).opacity(0.75)
|
||||
}
|
||||
} else {
|
||||
Text(snapshot?.labels.noTrip ?? "").font(.system(size: 12, weight: .medium)).lineLimit(2)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
default:
|
||||
if let trip, let snapshot {
|
||||
if today != nil, let next {
|
||||
Text("\(Image(systemName: "mappin.circle.fill")) \(next.time ?? "") \(next.name)")
|
||||
} else {
|
||||
Text("\(Image(systemName: "airplane")) \(trip.title), \(snapshot.labels.inDays.replacingOccurrences(of: "{n}", with: "\(days)"))")
|
||||
}
|
||||
} else {
|
||||
Text("\(Image(systemName: "map.fill")) TREK")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,112 @@
|
||||
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, in the widgets' look.
|
||||
struct TripDayLiveActivity: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: TripDayAttributes.self) { context in
|
||||
TripDayBanner(attributes: context.attributes, state: context.state)
|
||||
.activityBackgroundTint(Color(hex: 0x0C0C0F).opacity(0.92))
|
||||
.activitySystemActionForegroundColor(.white)
|
||||
.widgetURL(WidgetSnapshot.link(context.attributes.path))
|
||||
} dynamicIsland: { context in
|
||||
let state = context.state
|
||||
return DynamicIsland {
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "mappin.circle.fill")
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(Trek.confirmed)
|
||||
Text(state.dayLabel).font(.system(size: 13, weight: .bold)).lineLimit(1)
|
||||
}
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
if let time = state.nextTime {
|
||||
Text(time).font(Trek.number(20, .heavy)).padding(.trailing, 4)
|
||||
}
|
||||
}
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(state.nextName).font(.system(size: 17, weight: .heavy)).lineLimit(1)
|
||||
StopProgress(done: state.done, total: state.total)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
} compactLeading: {
|
||||
Image(systemName: "mappin.circle.fill").foregroundStyle(Trek.confirmed)
|
||||
} compactTrailing: {
|
||||
Text(state.nextTime ?? "\(state.done)/\(state.total)")
|
||||
.font(Trek.number(13, .heavy))
|
||||
} minimal: {
|
||||
ZStack {
|
||||
Circle().stroke(Color.white.opacity(0.25), lineWidth: 2.5)
|
||||
Circle()
|
||||
.trim(from: 0, to: state.total > 0 ? Double(state.done) / Double(state.total) : 0)
|
||||
.stroke(Trek.confirmed, style: StrokeStyle(lineWidth: 2.5, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
}
|
||||
.padding(2)
|
||||
}
|
||||
.widgetURL(WidgetSnapshot.link(context.attributes.path))
|
||||
.keylineTint(Trek.confirmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The lock-screen banner: trip and day on top, the next stop large, the
|
||||
/// day's stops as segments.
|
||||
private struct TripDayBanner: View {
|
||||
let attributes: TripDayAttributes
|
||||
let state: TripDayAttributes.ContentState
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 14) {
|
||||
ZStack {
|
||||
Circle().stroke(Color.white.opacity(0.18), lineWidth: 5)
|
||||
Circle()
|
||||
.trim(from: 0, to: state.total > 0 ? max(0.02, Double(state.done) / Double(state.total)) : 0)
|
||||
.stroke(
|
||||
AngularGradient(colors: [Trek.confirmed.opacity(0.7), Trek.confirmed], center: .center),
|
||||
style: StrokeStyle(lineWidth: 5, lineCap: .round)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
Image(systemName: "mappin.and.ellipse").font(.system(size: 17, weight: .bold)).foregroundStyle(.white)
|
||||
}
|
||||
.frame(width: 52, height: 52)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack {
|
||||
Text(attributes.tripTitle)
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.tracking(0.5)
|
||||
.textCase(.uppercase)
|
||||
.foregroundStyle(.white.opacity(0.65))
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
Text(state.dayLabel).font(.system(size: 11, weight: .bold)).foregroundStyle(.white.opacity(0.65)).lineLimit(1)
|
||||
}
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(state.nextName).font(.system(size: 18, weight: .heavy)).foregroundStyle(.white).lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
if let time = state.nextTime {
|
||||
Text(time).font(Trek.number(18, .heavy)).foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
StopProgress(done: state.done, total: state.total).padding(.top, 3)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
|
||||
/// The day's timed stops as segments, those behind filled.
|
||||
private struct StopProgress: View {
|
||||
let done: Int
|
||||
let total: Int
|
||||
|
||||
var body: some View {
|
||||
DaySegments(index: done + 1, total: max(total, 1), color: Trek.confirmed, track: Color.white.opacity(0.2))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import AppIntents
|
||||
import WidgetKit
|
||||
|
||||
/// The trip a widget shows, picked in "Edit Widget": the dashboard's trip by
|
||||
/// default ("Automatic"), or any trip the app listed in widget.trips.
|
||||
struct TripChoice: AppEntity {
|
||||
static var typeDisplayRepresentation: TypeDisplayRepresentation = "widget.intent.trip"
|
||||
static var defaultQuery = TripChoiceQuery()
|
||||
|
||||
/// The id of the automatic choice, which follows the dashboard's trip.
|
||||
static let automaticID = "auto"
|
||||
|
||||
var id: String
|
||||
var title: String
|
||||
var dates: String?
|
||||
|
||||
var displayRepresentation: DisplayRepresentation {
|
||||
if id == TripChoice.automaticID {
|
||||
return DisplayRepresentation(title: "widget.intent.auto", subtitle: "widget.intent.autoHint")
|
||||
}
|
||||
return DisplayRepresentation(title: "\(title)", subtitle: dates.map { "\($0)" })
|
||||
}
|
||||
|
||||
static var automatic: TripChoice { TripChoice(id: automaticID, title: "", dates: nil) }
|
||||
}
|
||||
|
||||
struct TripChoiceQuery: EntityQuery {
|
||||
func entities(for identifiers: [TripChoice.ID]) async throws -> [TripChoice] {
|
||||
all().filter { identifiers.contains($0.id) }
|
||||
}
|
||||
|
||||
func suggestedEntities() async throws -> [TripChoice] {
|
||||
all()
|
||||
}
|
||||
|
||||
func defaultResult() async -> TripChoice? {
|
||||
.automatic
|
||||
}
|
||||
|
||||
/// Automatic first, then the trips the app sent, soonest first.
|
||||
private func all() -> [TripChoice] {
|
||||
[.automatic] + WidgetSnapshot.tripIndex().map {
|
||||
TripChoice(id: String($0.id), title: $0.title, dates: RelativeDay.range($0.start, $0.end))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SelectTripIntent: WidgetConfigurationIntent {
|
||||
static var title: LocalizedStringResource = "widget.intent.title"
|
||||
static var description = IntentDescription("widget.intent.description")
|
||||
|
||||
@Parameter(title: "widget.intent.trip")
|
||||
var trip: TripChoice?
|
||||
}
|
||||
|
||||
/// Like TrekProvider, for the widgets that show one trip: the snapshot of the
|
||||
/// trip picked for the widget, or the dashboard's.
|
||||
struct TrekTripProvider: AppIntentTimelineProvider {
|
||||
func placeholder(in context: Context) -> TrekEntry {
|
||||
TrekEntry(date: Date(), snapshot: .sample(running: false))
|
||||
}
|
||||
|
||||
func snapshot(for configuration: SelectTripIntent, in context: Context) async -> TrekEntry {
|
||||
let stored = WidgetSnapshot.load(trip: configuration.trip?.id)
|
||||
let snapshot: WidgetSnapshot? = (stored?.trip != nil || !context.isPreview) ? stored : WidgetSnapshot.sample(running: false)
|
||||
return TrekEntry(date: Date(), snapshot: snapshot)
|
||||
}
|
||||
|
||||
func timeline(for configuration: SelectTripIntent, in context: Context) async -> Timeline<TrekEntry> {
|
||||
TrekProvider.timeline(WidgetSnapshot.load(trip: configuration.trip?.id))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
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.
|
||||
///
|
||||
/// Everything past `trip`, `today` and `labels` is optional: a binary that is
|
||||
/// newer than the server's UI shows what it gets, an older one ignores the rest.
|
||||
struct WidgetSnapshot: Codable {
|
||||
struct Trip: Codable {
|
||||
var title: String
|
||||
var start: String
|
||||
var end: String
|
||||
var path: String
|
||||
/// The destination in a few words ("Tokyo, Japan").
|
||||
var place: String?
|
||||
/// The trip's cover, a small JPEG as base64, already cropped for a widget.
|
||||
var cover: String?
|
||||
var dayCount: Int?
|
||||
var placeCount: Int?
|
||||
var memberCount: Int?
|
||||
}
|
||||
|
||||
struct Stop: Codable, Hashable {
|
||||
var time: String?
|
||||
var name: String
|
||||
/// Minutes after midnight, for the now line.
|
||||
var minutes: Int?
|
||||
/// The category colour as #rrggbb.
|
||||
var color: String?
|
||||
/// An SF Symbol name for the stop's kind.
|
||||
var symbol: String?
|
||||
}
|
||||
|
||||
struct Today: Codable {
|
||||
var dayLabel: String
|
||||
var stops: [Stop]
|
||||
/// 1-based position of today in the trip.
|
||||
var index: Int?
|
||||
var total: Int?
|
||||
}
|
||||
|
||||
struct Weather: Codable {
|
||||
var temp: Int
|
||||
var high: Int?
|
||||
var low: Int?
|
||||
/// The provider's main condition ("Clear", "Clouds", "Rain", ...).
|
||||
var main: String
|
||||
var label: String
|
||||
var place: String?
|
||||
/// Chance of rain in percent.
|
||||
var rain: Int?
|
||||
}
|
||||
|
||||
struct Booking: Codable, Hashable {
|
||||
var title: String
|
||||
/// flight, train, bus, car, cruise, hotel, restaurant, event, tour, other
|
||||
var kind: String
|
||||
/// yyyy-MM-dd, for the relative day.
|
||||
var date: String?
|
||||
var time: String?
|
||||
/// "FRA → HND", the location or the hotel's nights.
|
||||
var detail: String?
|
||||
var confirmed: Bool
|
||||
var path: String
|
||||
}
|
||||
|
||||
struct Todo: Codable, Hashable {
|
||||
var name: String
|
||||
var due: String?
|
||||
/// 0 none, 1 low, 2 medium, 3 high.
|
||||
var priority: Int
|
||||
}
|
||||
|
||||
struct Todos: Codable {
|
||||
var open: Int
|
||||
var total: Int
|
||||
var items: [Todo]
|
||||
var path: String
|
||||
}
|
||||
|
||||
struct Packing: Codable {
|
||||
var packed: Int
|
||||
var total: Int
|
||||
var path: String
|
||||
var bags: [Bag]?
|
||||
var missing: [Item]?
|
||||
|
||||
struct Bag: Codable, Hashable {
|
||||
var name: String
|
||||
/// #rrggbb
|
||||
var color: String
|
||||
var grams: Int
|
||||
/// The bag's weight limit in grams, if it has one.
|
||||
var limit: Int?
|
||||
var packed: Int
|
||||
var total: Int
|
||||
}
|
||||
|
||||
struct Item: Codable, Hashable {
|
||||
var name: String
|
||||
var category: String?
|
||||
/// The colour of the bag it goes in.
|
||||
var color: String?
|
||||
}
|
||||
}
|
||||
|
||||
/// The countries of the Atlas, independent of any trip.
|
||||
struct Atlas: Codable {
|
||||
var count: Int
|
||||
/// ISO 3166-1 alpha-2 codes.
|
||||
var codes: [String]
|
||||
var recent: [Country]
|
||||
var continents: [Continent]
|
||||
var trips: Int?
|
||||
var days: Int?
|
||||
|
||||
struct Country: Codable, Hashable {
|
||||
var code: String
|
||||
var name: String
|
||||
}
|
||||
|
||||
struct Continent: Codable, Hashable {
|
||||
var name: String
|
||||
var count: Int
|
||||
}
|
||||
}
|
||||
|
||||
/// Who travels along, the trip chat and its open poll.
|
||||
struct Collab: Codable {
|
||||
var members: [Member]
|
||||
var messages: [Message]
|
||||
var poll: Poll?
|
||||
var path: String
|
||||
|
||||
struct Member: Codable, Hashable {
|
||||
var name: String
|
||||
var initials: String
|
||||
}
|
||||
|
||||
struct Message: Codable, Hashable {
|
||||
var author: String
|
||||
var initials: String
|
||||
var text: String
|
||||
var at: String?
|
||||
var mine: Bool
|
||||
}
|
||||
|
||||
struct Poll: Codable {
|
||||
var question: String
|
||||
var options: [Option]
|
||||
var voters: Int
|
||||
|
||||
struct Option: Codable, Hashable {
|
||||
var label: String
|
||||
var votes: Int
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Labels: Codable {
|
||||
var nextTrip: String
|
||||
var today: String
|
||||
var noTrip: String
|
||||
/// Contains "{n}".
|
||||
var inDays: String
|
||||
var tomorrow: String
|
||||
var nothingPlanned: String
|
||||
var days: String?
|
||||
var dayOf: String?
|
||||
var bookings: String?
|
||||
var noBookings: String?
|
||||
var todos: String?
|
||||
var noTodos: String?
|
||||
var packing: String?
|
||||
var packed: String?
|
||||
var confirmed: String?
|
||||
var pending: String?
|
||||
var todayShort: String?
|
||||
var tomorrowShort: String?
|
||||
var now: String?
|
||||
var places: String?
|
||||
var travellers: String?
|
||||
var allDone: String?
|
||||
var openTrek: String?
|
||||
var missing: String?
|
||||
var countries: String?
|
||||
var world: String?
|
||||
var group: String?
|
||||
var poll: String?
|
||||
var votes: String?
|
||||
var noMessages: String?
|
||||
var departure: String?
|
||||
}
|
||||
|
||||
var trip: Trip?
|
||||
var today: Today?
|
||||
var weather: Weather?
|
||||
var bookings: [Booking]?
|
||||
var todos: Todos?
|
||||
var packing: Packing?
|
||||
var atlas: Atlas?
|
||||
var collab: Collab?
|
||||
var labels: Labels
|
||||
|
||||
/// The dashboard's snapshot.
|
||||
static func load() -> WidgetSnapshot? {
|
||||
read("widget.snapshot").flatMap { try? JSONDecoder().decode(WidgetSnapshot.self, from: $0) }
|
||||
}
|
||||
|
||||
/// The snapshot of the trip a widget is set to, the dashboard's for none or
|
||||
/// "auto", and the dashboard's too while the app has not written that trip yet.
|
||||
static func load(trip id: String?) -> WidgetSnapshot? {
|
||||
guard let id, id != "auto", let data = read("widget.trip.\(id)"),
|
||||
var snapshot = try? JSONDecoder().decode(WidgetSnapshot.self, from: data) else { return load() }
|
||||
if snapshot.atlas == nil { snapshot.atlas = load()?.atlas }
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/// A trip a widget can be set to, as the app lists them.
|
||||
struct TripRef: Codable {
|
||||
var id: Int
|
||||
var title: String
|
||||
var start: String
|
||||
var end: String
|
||||
}
|
||||
|
||||
static func tripIndex() -> [TripRef] {
|
||||
read("widget.trips").flatMap { try? JSONDecoder().decode([TripRef].self, from: $0) } ?? []
|
||||
}
|
||||
|
||||
private static func read(_ account: String) -> Data? {
|
||||
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?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else { return nil }
|
||||
return result as? 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 }
|
||||
return WidgetSnapshot.daysBetween(now, start)
|
||||
}
|
||||
|
||||
/// The trip's length in days, from its dates when the app sent no count.
|
||||
var tripLength: Int? {
|
||||
if let count = trip?.dayCount, count > 0 { return count }
|
||||
guard let trip, let start = WidgetSnapshot.day(trip.start), let end = WidgetSnapshot.day(trip.end),
|
||||
let days = WidgetSnapshot.daysBetween(start, end) else { return nil }
|
||||
return days + 1
|
||||
}
|
||||
|
||||
static func daysBetween(_ from: Date, _ to: Date) -> Int? {
|
||||
let calendar = Calendar.current
|
||||
return calendar.dateComponents([.day], from: calendar.startOfDay(for: from), to: calendar.startOfDay(for: to)).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,53 @@
|
||||
// Generated by client/ios/WidgetPreviews/make-world-grid.py from
|
||||
// server/assets/atlas/admin0.geojson.gz; do not edit by hand.
|
||||
import Foundation
|
||||
|
||||
/// The world as 76 by 34 dots from 80° north to 58° south,
|
||||
/// each the ISO code of the country under it, ".." for sea (977 land dots).
|
||||
enum WorldGrid {
|
||||
static let columns = 76
|
||||
static let rows: [String] = [
|
||||
"........................CACACA..CACACACACACA..GLGLGLGLGLGLGLGLGLGLGL............NONONO..................RU..........RURURU..............................",
|
||||
"......................CACACACACA..CACACACACA........GLGLGLGLGLGLGLGL..............................RURU....RURURURURURURURURURURURURU..RURURU............",
|
||||
"......USUSUSUSUSCACACACACACACACACACACACACACACACA....GLGLGLGLGLGLGL................NONONORURU....RURU..RURURURURURURURURURURURURURURURURURURURURURURURURU",
|
||||
"RURU..USUSUSUSUSCACACACACACACACACACACACACACACACACA....GLGLGLGL....ISIS..........NOSESEFIRURURURURURURURURURURURURURURURURURURURURURURURURURURURURURURURU",
|
||||
"......USUSUSUSUSCACACACACACACACACACACA....CACACACA....GLGL....................NONOSEFIFIRURURURURURURURURURURURURURURURURURURURURURURURURURURURURURURURU",
|
||||
"........USUS......USCACACACACACACACACACA..CACACACA........................GB..NOSESELVLVRURURURURURURURURURURURURURURURURURURURURURURURU......RURU......",
|
||||
"......US............CACACACACACACACACACACACACACACACA....................IEGBGBNLDEPLPLBYBYRURURURURURUKZKZRURURURURURURURURURURURURURURURU....RU........",
|
||||
"......................CACACACACACACACACACACACACACACACA....................FRFRDEDECZPLUAUAUARURUKZKZKZKZKZKZKZKZRUMNMNMNMNRUMNCNCNRURURURU..............",
|
||||
"........................USUSUSUSUSUSUSUSCACACAUSCA........................FRFRITITHRROROMDUARURURUKZKZKZKZKZKZCNCNMNMNMNMNMNCNCNCNCNRU..................",
|
||||
"......................USUSUSUSUSUSUSUSUSUSUSUS..........................ESESES..ITITALBGTRTRTRGEAZTMTMUZKZKGKGCNCNCNCNCNCNCNCNCNCNCN..JPJP..............",
|
||||
"........................USUSUSUSUSUSUSUSUSUS............................ESESDZDZ....GRTRTRTRTRIRIRIRIRTMTJTJCNCNCNCNCNCNCNCNCNCNKPKR..JP................",
|
||||
"..........................USUSUSUSUSUSUSUSUS............................MAMADZDZTN..LY....LBIQIQIRIRIRAFAFPKINCNCNCNCNCNCNCNCNCNKRJPJP..................",
|
||||
"..........................MXMXMXMXUSUS..US............................MADZDZDZDZLYLYLYEGEGSASAIQIRIRIRPKPKININNPCNCNCNCNCNCNCNCN........................",
|
||||
"............................MXMXMXUS....US..........................EHEHMRDZDZDZLYLYLYEGEGSASASASAAEIRPKPKINININININMMCNCNCNCNCN........................",
|
||||
"..............................MXMXMX..MXCUCUBS......................MRMRMRMLDZDZNETDLYSDSDSDSASASASAOM..INININININMMMMLAVNCN............................",
|
||||
"................................MXMXMXMX..JM........................MRMRMRMLMLNENETDTDSDSDSDERYEYEOM......INININ....MMTHVN....PH........................",
|
||||
"....................................GTSVNI....VE....................SNSNMLBFNENENGTDTDSDSDSDETYEYE........ININ......MMTHKH....PH........................",
|
||||
"........................................CRPACOVEVEVE..................SLCIGHBJNGNGTDCFSSSSETETSOSO..........IN....IN....VN......PH......................",
|
||||
"..........................................COCOCOVEGYSR..................CICI..NGCMCFCFCDSSKEETSOSO..................IDMY....MY..ID......................",
|
||||
"..........................................ECCOBRBRBRBRBR........................GACGCDCDUGKEKESO......................ID..IDIDID........................",
|
||||
"..........................................ECPEBRBRBRBRBRBRBR....................CGCDCDCDBITZKE........................IDIDIDIDID..IDIDIDPG..PG..........",
|
||||
"..........................................PEBRBRBRBRBRBRBRBRBR..................AOAOCDCDTZTZTZ..........................IDIDID........IDPGPGPG..........",
|
||||
"..........................................PEPEBOBRBRBRBRBRBRBR....................AOAOCDZMTZMZKM..................................AUAU....PG............",
|
||||
"............................................PEBOBOBRBRBRBRBR....................AOAOAOZMMZMZMZMGMG..............................AUAUAU..AU..............",
|
||||
"..............................................CLBOPYBRBRBRBR....................NANABWBWZWMZ..MGMG............................AUAUAUAUAUAUAU....FR......",
|
||||
"..............................................CLARPYBRBRBR........................NABWBWZAMZ..MG............................AUAUAUAUAUAUAUAU............",
|
||||
"..............................................ARARARBRBR..........................NAZAZAZA..................................AUAUAUAUAUAUAUAUAU..........",
|
||||
"..............................................ARARARUY............................ZAZAZAZA..................................AUAUAUAUAUAUAUAUAU..........",
|
||||
"............................................CLARARARUY..............................ZA......................................AU........AUAUAU............",
|
||||
"............................................CLARARAR....................................................................................AU............NZ",
|
||||
"............................................CLARAR......................................................................................AU........NZNZ..",
|
||||
"............................................CLAR........................................................................................................",
|
||||
"............................................CLAR........................................................................................................",
|
||||
"..............................................CL........................................................................................................",
|
||||
]
|
||||
|
||||
/// The country code of a dot, nil for sea.
|
||||
static func code(row: Int, column: Int) -> String? {
|
||||
let line = rows[row]
|
||||
let start = line.index(line.startIndex, offsetBy: column * 2)
|
||||
let code = String(line[start..<line.index(start, offsetBy: 2)])
|
||||
return code == ".." ? nil : code
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "رحلة";
|
||||
"widget.trip.description" = "عدّ تنازلي لرحلتك القادمة، ثم اليوم وما يليه أثناء السفر.";
|
||||
"widget.today.name" = "اليوم";
|
||||
"widget.today.description" = "خطة اليوم كخط زمني، مع الطقس والمحطة التالية.";
|
||||
"widget.bookings.name" = "الحجوزات";
|
||||
"widget.bookings.description" = "رحلاتك وقطاراتك وفنادقك وحجوزات المطاعم القادمة ومواعيدها.";
|
||||
"widget.todos.name" = "المهام";
|
||||
"widget.todos.description" = "ما تبقى للرحلة، ومدى تقدم تجهيز الأمتعة.";
|
||||
"widget.packing.name" = "قائمة الأمتعة";
|
||||
"widget.packing.description" = "مدى امتلاء الحقائب ووزنها وما الذي ما زال ناقصًا.";
|
||||
"widget.atlas.name" = "خريطة العالم";
|
||||
"widget.atlas.description" = "الدول التي زرتها مضيئة على الخريطة.";
|
||||
"widget.collab.name" = "مجموعة السفر";
|
||||
"widget.collab.description" = "من يرافقك وأحدث الرسائل والتصويت المفتوح.";
|
||||
"widget.intent.title" = "اختر الرحلة";
|
||||
"widget.intent.description" = "الرحلة التي تعرضها هذه الأداة.";
|
||||
"widget.intent.trip" = "الرحلة";
|
||||
"widget.intent.auto" = "تلقائي";
|
||||
"widget.intent.autoHint" = "نفس رحلة لوحة التحكم";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Səyahət";
|
||||
"widget.trip.description" = "Növbəti səyahətə qədər günləri sayır, yolda isə günü və sonrakını göstərir.";
|
||||
"widget.today.name" = "Bu gün";
|
||||
"widget.today.description" = "Günün planı zaman xətti kimi, hava və növbəti dayanacaqla.";
|
||||
"widget.bookings.name" = "Bronlar";
|
||||
"widget.bookings.description" = "Növbəti uçuşlar, qatarlar, otellər və restoranlar, nə vaxt olduqları ilə.";
|
||||
"widget.todos.name" = "Tapşırıqlar";
|
||||
"widget.todos.description" = "Səyahət üçün hələ nəyin qaldığı və yığışmağın nə yerdə olduğu.";
|
||||
"widget.packing.name" = "Yük siyahısı";
|
||||
"widget.packing.description" = "Çantaların doluluğu, çəkisi və hələ nəyin çatışmadığı.";
|
||||
"widget.atlas.name" = "Dünya xəritəsi";
|
||||
"widget.atlas.description" = "Olduğun ölkələr xəritədə parlayır.";
|
||||
"widget.collab.name" = "Səyahət qrupu";
|
||||
"widget.collab.description" = "Kim gəlir, son mesajlar və açıq səsvermə.";
|
||||
"widget.intent.title" = "Səyahəti seç";
|
||||
"widget.intent.description" = "Bu vidcetin göstərdiyi səyahət.";
|
||||
"widget.intent.trip" = "Səyahət";
|
||||
"widget.intent.auto" = "Avtomatik";
|
||||
"widget.intent.autoHint" = "İdarə panelindəki səyahət";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Viatge";
|
||||
"widget.trip.description" = "Compta els dies fins al proper viatge i, durant el viatge, mostra el dia i el que ve després.";
|
||||
"widget.today.name" = "Avui";
|
||||
"widget.today.description" = "El pla del dia com a línia de temps, amb el temps i la propera parada.";
|
||||
"widget.bookings.name" = "Reserves";
|
||||
"widget.bookings.description" = "Els teus propers vols, trens, hotels i taules, i quan toquen.";
|
||||
"widget.todos.name" = "Tasques";
|
||||
"widget.todos.description" = "El que encara queda per al viatge i com va l'equipatge.";
|
||||
"widget.packing.name" = "Equipatge";
|
||||
"widget.packing.description" = "Com de plenes són les maletes, el pes i el que encara falta.";
|
||||
"widget.atlas.name" = "Mapa del món";
|
||||
"widget.atlas.description" = "Els països on has estat, brillant al mapa.";
|
||||
"widget.collab.name" = "Grup de viatge";
|
||||
"widget.collab.description" = "Qui ve, els darrers missatges i la votació oberta.";
|
||||
"widget.intent.title" = "Tria el viatge";
|
||||
"widget.intent.description" = "El viatge que mostra aquest giny.";
|
||||
"widget.intent.trip" = "Viatge";
|
||||
"widget.intent.auto" = "Automàtic";
|
||||
"widget.intent.autoHint" = "El mateix viatge que el tauler";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Cesta";
|
||||
"widget.trip.description" = "Odpočítává dny do další cesty a během ní ukazuje den a co přijde dál.";
|
||||
"widget.today.name" = "Dnes";
|
||||
"widget.today.description" = "Plán dne jako časová osa, s počasím a další zastávkou.";
|
||||
"widget.bookings.name" = "Rezervace";
|
||||
"widget.bookings.description" = "Tvoje další lety, vlaky, hotely a stoly, a kdy tě čekají.";
|
||||
"widget.todos.name" = "Úkoly";
|
||||
"widget.todos.description" = "Co ještě zbývá k cestě a jak daleko je balení.";
|
||||
"widget.packing.name" = "Balení";
|
||||
"widget.packing.description" = "Jak plná jsou zavazadla, jejich váha a co ještě chybí.";
|
||||
"widget.atlas.name" = "Mapa světa";
|
||||
"widget.atlas.description" = "Země, kde jsi byl, zářící na mapě.";
|
||||
"widget.collab.name" = "Cestovní skupina";
|
||||
"widget.collab.description" = "Kdo jede s tebou, nejnovější zprávy a otevřené hlasování.";
|
||||
"widget.intent.title" = "Vybrat cestu";
|
||||
"widget.intent.description" = "Cesta, kterou tento widget zobrazuje.";
|
||||
"widget.intent.trip" = "Cesta";
|
||||
"widget.intent.auto" = "Automaticky";
|
||||
"widget.intent.autoHint" = "Stejná cesta jako na nástěnce";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Reise";
|
||||
"widget.trip.description" = "Zählt die Tage bis zur nächsten Reise und zeigt unterwegs den Tag und was als Nächstes kommt.";
|
||||
"widget.today.name" = "Heute";
|
||||
"widget.today.description" = "Der Tagesplan als Zeitleiste, mit dem Wetter und dem nächsten Stopp.";
|
||||
"widget.bookings.name" = "Buchungen";
|
||||
"widget.bookings.description" = "Deine nächsten Flüge, Züge, Hotels und Tische, und wann sie anstehen.";
|
||||
"widget.todos.name" = "To-dos";
|
||||
"widget.todos.description" = "Was für die Reise noch offen ist und wie weit das Packen ist.";
|
||||
"widget.packing.name" = "Packliste";
|
||||
"widget.packing.description" = "Wie voll die Taschen sind, ihr Gewicht und was noch fehlt.";
|
||||
"widget.atlas.name" = "Weltkarte";
|
||||
"widget.atlas.description" = "Die Länder, in denen du warst, leuchtend auf der Karte.";
|
||||
"widget.collab.name" = "Reisegruppe";
|
||||
"widget.collab.description" = "Wer mitreist, die neuesten Nachrichten und die offene Abstimmung.";
|
||||
"widget.intent.title" = "Reise wählen";
|
||||
"widget.intent.description" = "Die Reise, die dieses Widget zeigt.";
|
||||
"widget.intent.trip" = "Reise";
|
||||
"widget.intent.auto" = "Automatisch";
|
||||
"widget.intent.autoHint" = "Dieselbe Reise wie im Dashboard";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Ταξίδι";
|
||||
"widget.trip.description" = "Μετρά αντίστροφα ως το επόμενο ταξίδι και στη διάρκειά του δείχνει τη μέρα και τι ακολουθεί.";
|
||||
"widget.today.name" = "Σήμερα";
|
||||
"widget.today.description" = "Το πρόγραμμα της ημέρας ως χρονολόγιο, με τον καιρό και την επόμενη στάση.";
|
||||
"widget.bookings.name" = "Κρατήσεις";
|
||||
"widget.bookings.description" = "Οι επόμενες πτήσεις, τα τρένα, τα ξενοδοχεία και τα τραπέζια σου, και πότε είναι.";
|
||||
"widget.todos.name" = "Εκκρεμότητες";
|
||||
"widget.todos.description" = "Τι μένει ακόμα για το ταξίδι και πόσο έχει προχωρήσει το πακετάρισμα.";
|
||||
"widget.packing.name" = "Αποσκευές";
|
||||
"widget.packing.description" = "Πόσο γεμάτες είναι οι τσάντες, το βάρος τους και τι λείπει ακόμα.";
|
||||
"widget.atlas.name" = "Παγκόσμιος χάρτης";
|
||||
"widget.atlas.description" = "Οι χώρες που έχεις επισκεφθεί, φωτεινές στον χάρτη.";
|
||||
"widget.collab.name" = "Ομάδα ταξιδιού";
|
||||
"widget.collab.description" = "Ποιος έρχεται, τα τελευταία μηνύματα και η ανοιχτή ψηφοφορία.";
|
||||
"widget.intent.title" = "Επιλογή ταξιδιού";
|
||||
"widget.intent.description" = "Το ταξίδι που δείχνει αυτό το widget.";
|
||||
"widget.intent.trip" = "Ταξίδι";
|
||||
"widget.intent.auto" = "Αυτόματα";
|
||||
"widget.intent.autoHint" = "Το ίδιο ταξίδι με τον πίνακα";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Trip";
|
||||
"widget.trip.description" = "Counts down to your next trip, then shows the day and what comes next.";
|
||||
"widget.today.name" = "Today";
|
||||
"widget.today.description" = "Today's plan as a timeline, with the weather and what is next.";
|
||||
"widget.bookings.name" = "Bookings";
|
||||
"widget.bookings.description" = "Your next flights, trains, hotels and tables, and when they are due.";
|
||||
"widget.todos.name" = "To-dos";
|
||||
"widget.todos.description" = "What is still open for the trip, and how far the packing has come.";
|
||||
"widget.packing.name" = "Packing";
|
||||
"widget.packing.description" = "How full the bags are, their weight, and what is still missing.";
|
||||
"widget.atlas.name" = "World map";
|
||||
"widget.atlas.description" = "The countries you have been to, glowing on the map.";
|
||||
"widget.collab.name" = "Travel group";
|
||||
"widget.collab.description" = "Who comes along, the latest messages and the open poll.";
|
||||
"widget.intent.title" = "Choose trip";
|
||||
"widget.intent.description" = "The trip this widget shows.";
|
||||
"widget.intent.trip" = "Trip";
|
||||
"widget.intent.auto" = "Automatic";
|
||||
"widget.intent.autoHint" = "Same trip as the dashboard";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Viaje";
|
||||
"widget.trip.description" = "Cuenta los días hasta tu próximo viaje y, durante él, muestra el día y lo que sigue.";
|
||||
"widget.today.name" = "Hoy";
|
||||
"widget.today.description" = "El plan del día como línea de tiempo, con el tiempo y la próxima parada.";
|
||||
"widget.bookings.name" = "Reservas";
|
||||
"widget.bookings.description" = "Tus próximos vuelos, trenes, hoteles y mesas, y cuándo tocan.";
|
||||
"widget.todos.name" = "Tareas";
|
||||
"widget.todos.description" = "Lo que aún queda para el viaje y cómo va la maleta.";
|
||||
"widget.packing.name" = "Equipaje";
|
||||
"widget.packing.description" = "Lo llenas que van las maletas, su peso y lo que aún falta.";
|
||||
"widget.atlas.name" = "Mapa del mundo";
|
||||
"widget.atlas.description" = "Los países donde has estado, brillando en el mapa.";
|
||||
"widget.collab.name" = "Grupo de viaje";
|
||||
"widget.collab.description" = "Quién viene, los últimos mensajes y la votación abierta.";
|
||||
"widget.intent.title" = "Elegir viaje";
|
||||
"widget.intent.description" = "El viaje que muestra este widget.";
|
||||
"widget.intent.trip" = "Viaje";
|
||||
"widget.intent.auto" = "Automático";
|
||||
"widget.intent.autoHint" = "El mismo viaje que el panel";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Reis";
|
||||
"widget.trip.description" = "Loeb päevi järgmise reisini ja näitab reisi ajal päeva ning seda, mis järgmisena tuleb.";
|
||||
"widget.today.name" = "Täna";
|
||||
"widget.today.description" = "Päeva plaan ajajoonena, koos ilma ja järgmise peatusega.";
|
||||
"widget.bookings.name" = "Broneeringud";
|
||||
"widget.bookings.description" = "Sinu järgmised lennud, rongid, hotellid ja lauad ning millal need on.";
|
||||
"widget.todos.name" = "Ülesanded";
|
||||
"widget.todos.description" = "Mis reisi jaoks veel tegemata on ja kui kaugel pakkimine on.";
|
||||
"widget.packing.name" = "Pakkimine";
|
||||
"widget.packing.description" = "Kui täis kotid on, nende kaal ja mis veel puudu on.";
|
||||
"widget.atlas.name" = "Maailmakaart";
|
||||
"widget.atlas.description" = "Riigid, kus oled käinud, kaardil helendamas.";
|
||||
"widget.collab.name" = "Reisiseltskond";
|
||||
"widget.collab.description" = "Kes tuleb kaasa, viimased sõnumid ja avatud hääletus.";
|
||||
"widget.intent.title" = "Vali reis";
|
||||
"widget.intent.description" = "Reis, mida see vidin näitab.";
|
||||
"widget.intent.trip" = "Reis";
|
||||
"widget.intent.auto" = "Automaatne";
|
||||
"widget.intent.autoHint" = "Sama reis mis töölaual";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Voyage";
|
||||
"widget.trip.description" = "Compte les jours jusqu'au prochain voyage, puis montre la journée et la suite pendant le voyage.";
|
||||
"widget.today.name" = "Aujourd'hui";
|
||||
"widget.today.description" = "Le programme du jour en frise, avec la météo et la prochaine étape.";
|
||||
"widget.bookings.name" = "Réservations";
|
||||
"widget.bookings.description" = "Vos prochains vols, trains, hôtels et tables, et quand ils arrivent.";
|
||||
"widget.todos.name" = "À faire";
|
||||
"widget.todos.description" = "Ce qui reste à faire pour le voyage et où en sont les bagages.";
|
||||
"widget.packing.name" = "Bagages";
|
||||
"widget.packing.description" = "Le remplissage des sacs, leur poids et ce qui manque encore.";
|
||||
"widget.atlas.name" = "Carte du monde";
|
||||
"widget.atlas.description" = "Les pays où vous êtes allé, illuminés sur la carte.";
|
||||
"widget.collab.name" = "Groupe de voyage";
|
||||
"widget.collab.description" = "Qui vient, les derniers messages et le sondage ouvert.";
|
||||
"widget.intent.title" = "Choisir le voyage";
|
||||
"widget.intent.description" = "Le voyage affiché par ce widget.";
|
||||
"widget.intent.trip" = "Voyage";
|
||||
"widget.intent.auto" = "Automatique";
|
||||
"widget.intent.autoHint" = "Le même voyage que le tableau de bord";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "טיול";
|
||||
"widget.trip.description" = "סופר את הימים עד הטיול הבא, ובמהלכו מציג את היום ואת מה שבא אחריו.";
|
||||
"widget.today.name" = "היום";
|
||||
"widget.today.description" = "תוכנית היום כציר זמן, עם מזג האוויר והעצירה הבאה.";
|
||||
"widget.bookings.name" = "הזמנות";
|
||||
"widget.bookings.description" = "הטיסות, הרכבות, המלונות והשולחנות הבאים שלך, ומתי הם.";
|
||||
"widget.todos.name" = "משימות";
|
||||
"widget.todos.description" = "מה עוד פתוח לטיול וכמה התקדמה האריזה.";
|
||||
"widget.packing.name" = "אריזה";
|
||||
"widget.packing.description" = "כמה מלאים התיקים, המשקל שלהם ומה עוד חסר.";
|
||||
"widget.atlas.name" = "מפת העולם";
|
||||
"widget.atlas.description" = "המדינות שביקרת בהן, זוהרות על המפה.";
|
||||
"widget.collab.name" = "קבוצת הטיול";
|
||||
"widget.collab.description" = "מי מצטרף, ההודעות האחרונות והסקר הפתוח.";
|
||||
"widget.intent.title" = "בחירת טיול";
|
||||
"widget.intent.description" = "הטיול שהווידג׳ט הזה מציג.";
|
||||
"widget.intent.trip" = "טיול";
|
||||
"widget.intent.auto" = "אוטומטי";
|
||||
"widget.intent.autoHint" = "אותו טיול כמו בלוח הבקרה";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Utazás";
|
||||
"widget.trip.description" = "Visszaszámol a következő utazásig, útközben pedig a napot és a következő programot mutatja.";
|
||||
"widget.today.name" = "Ma";
|
||||
"widget.today.description" = "A nap terve idővonalon, az időjárással és a következő megállóval.";
|
||||
"widget.bookings.name" = "Foglalások";
|
||||
"widget.bookings.description" = "A következő járataid, vonataid, szállásaid és asztalfoglalásaid, és hogy mikor esedékesek.";
|
||||
"widget.todos.name" = "Teendők";
|
||||
"widget.todos.description" = "Ami még hátravan az utazáshoz, és hol tart a csomagolás.";
|
||||
"widget.packing.name" = "Csomagolás";
|
||||
"widget.packing.description" = "Mennyire telik a táska, a súlya, és mi hiányzik még.";
|
||||
"widget.atlas.name" = "Világtérkép";
|
||||
"widget.atlas.description" = "Az országok, ahol jártál, világítva a térképen.";
|
||||
"widget.collab.name" = "Útitársak";
|
||||
"widget.collab.description" = "Ki jön veled, a legújabb üzenetek és a nyitott szavazás.";
|
||||
"widget.intent.title" = "Utazás választása";
|
||||
"widget.intent.description" = "Az utazás, amelyet ez a widget mutat.";
|
||||
"widget.intent.trip" = "Utazás";
|
||||
"widget.intent.auto" = "Automatikus";
|
||||
"widget.intent.autoHint" = "Ugyanaz az utazás, mint az irányítópulton";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Perjalanan";
|
||||
"widget.trip.description" = "Menghitung mundur ke perjalanan berikutnya, lalu menampilkan hari dan apa yang berikutnya.";
|
||||
"widget.today.name" = "Hari ini";
|
||||
"widget.today.description" = "Rencana hari ini sebagai linimasa, dengan cuaca dan perhentian berikutnya.";
|
||||
"widget.bookings.name" = "Pemesanan";
|
||||
"widget.bookings.description" = "Penerbangan, kereta, hotel, dan meja berikutnya, beserta waktunya.";
|
||||
"widget.todos.name" = "Tugas";
|
||||
"widget.todos.description" = "Yang masih tersisa untuk perjalanan dan sejauh mana persiapan barang.";
|
||||
"widget.packing.name" = "Barang bawaan";
|
||||
"widget.packing.description" = "Seberapa penuh tas, beratnya, dan yang masih kurang.";
|
||||
"widget.atlas.name" = "Peta dunia";
|
||||
"widget.atlas.description" = "Negara yang pernah kamu kunjungi, bersinar di peta.";
|
||||
"widget.collab.name" = "Rombongan";
|
||||
"widget.collab.description" = "Siapa yang ikut, pesan terbaru, dan polling yang terbuka.";
|
||||
"widget.intent.title" = "Pilih perjalanan";
|
||||
"widget.intent.description" = "Perjalanan yang ditampilkan widget ini.";
|
||||
"widget.intent.trip" = "Perjalanan";
|
||||
"widget.intent.auto" = "Otomatis";
|
||||
"widget.intent.autoHint" = "Perjalanan yang sama dengan dasbor";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Viaggio";
|
||||
"widget.trip.description" = "Conta i giorni al prossimo viaggio e durante il viaggio mostra la giornata e cosa viene dopo.";
|
||||
"widget.today.name" = "Oggi";
|
||||
"widget.today.description" = "Il programma del giorno come linea del tempo, con il meteo e la prossima tappa.";
|
||||
"widget.bookings.name" = "Prenotazioni";
|
||||
"widget.bookings.description" = "I tuoi prossimi voli, treni, hotel e tavoli, e quando sono.";
|
||||
"widget.todos.name" = "Da fare";
|
||||
"widget.todos.description" = "Cosa manca ancora per il viaggio e a che punto è la valigia.";
|
||||
"widget.packing.name" = "Bagagli";
|
||||
"widget.packing.description" = "Quanto sono piene le borse, il loro peso e cosa manca ancora.";
|
||||
"widget.atlas.name" = "Mappa del mondo";
|
||||
"widget.atlas.description" = "I paesi in cui sei stato, illuminati sulla mappa.";
|
||||
"widget.collab.name" = "Gruppo di viaggio";
|
||||
"widget.collab.description" = "Chi viene, gli ultimi messaggi e il sondaggio aperto.";
|
||||
"widget.intent.title" = "Scegli viaggio";
|
||||
"widget.intent.description" = "Il viaggio mostrato da questo widget.";
|
||||
"widget.intent.trip" = "Viaggio";
|
||||
"widget.intent.auto" = "Automatico";
|
||||
"widget.intent.autoHint" = "Lo stesso viaggio della dashboard";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "旅行";
|
||||
"widget.trip.description" = "次の旅行までをカウントダウンし、旅行中はその日の予定と次の行き先を表示します。";
|
||||
"widget.today.name" = "今日";
|
||||
"widget.today.description" = "今日の予定をタイムラインで、天気と次の予定とともに表示します。";
|
||||
"widget.bookings.name" = "予約";
|
||||
"widget.bookings.description" = "次のフライト、列車、ホテル、レストランとその日時。";
|
||||
"widget.todos.name" = "やること";
|
||||
"widget.todos.description" = "旅行のために残っていることと、荷造りの進み具合。";
|
||||
"widget.packing.name" = "持ち物";
|
||||
"widget.packing.description" = "バッグの詰め具合と重さ、まだ足りないもの。";
|
||||
"widget.atlas.name" = "世界地図";
|
||||
"widget.atlas.description" = "訪れた国が地図上で光ります。";
|
||||
"widget.collab.name" = "旅行グループ";
|
||||
"widget.collab.description" = "一緒に行くメンバー、最新のメッセージ、受付中の投票。";
|
||||
"widget.intent.title" = "旅行を選択";
|
||||
"widget.intent.description" = "このウィジェットに表示する旅行。";
|
||||
"widget.intent.trip" = "旅行";
|
||||
"widget.intent.auto" = "自動";
|
||||
"widget.intent.autoHint" = "ダッシュボードと同じ旅行";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "여행";
|
||||
"widget.trip.description" = "다음 여행까지 남은 날을 세고, 여행 중에는 오늘과 다음 일정을 보여 줍니다.";
|
||||
"widget.today.name" = "오늘";
|
||||
"widget.today.description" = "오늘 일정을 타임라인으로, 날씨와 다음 장소와 함께 보여 줍니다.";
|
||||
"widget.bookings.name" = "예약";
|
||||
"widget.bookings.description" = "다가오는 항공편, 기차, 호텔, 식당과 그 시간.";
|
||||
"widget.todos.name" = "할 일";
|
||||
"widget.todos.description" = "여행을 위해 남은 일과 짐 싸기 진행 상황.";
|
||||
"widget.packing.name" = "짐 싸기";
|
||||
"widget.packing.description" = "가방이 얼마나 찼는지, 무게, 아직 빠진 것.";
|
||||
"widget.atlas.name" = "세계 지도";
|
||||
"widget.atlas.description" = "다녀온 나라가 지도 위에서 빛납니다.";
|
||||
"widget.collab.name" = "여행 그룹";
|
||||
"widget.collab.description" = "함께 가는 사람, 최근 메시지, 진행 중인 투표.";
|
||||
"widget.intent.title" = "여행 선택";
|
||||
"widget.intent.description" = "이 위젯에 표시할 여행.";
|
||||
"widget.intent.trip" = "여행";
|
||||
"widget.intent.auto" = "자동";
|
||||
"widget.intent.autoHint" = "대시보드와 같은 여행";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Reis";
|
||||
"widget.trip.description" = "Telt af naar je volgende reis en toont onderweg de dag en wat er hierna komt.";
|
||||
"widget.today.name" = "Vandaag";
|
||||
"widget.today.description" = "Het dagplan als tijdlijn, met het weer en de volgende stop.";
|
||||
"widget.bookings.name" = "Boekingen";
|
||||
"widget.bookings.description" = "Je volgende vluchten, treinen, hotels en tafels, en wanneer ze zijn.";
|
||||
"widget.todos.name" = "Taken";
|
||||
"widget.todos.description" = "Wat er nog openstaat voor de reis en hoe ver het inpakken is.";
|
||||
"widget.packing.name" = "Bagage";
|
||||
"widget.packing.description" = "Hoe vol de tassen zijn, hun gewicht en wat er nog mist.";
|
||||
"widget.atlas.name" = "Wereldkaart";
|
||||
"widget.atlas.description" = "De landen waar je bent geweest, oplichtend op de kaart.";
|
||||
"widget.collab.name" = "Reisgezelschap";
|
||||
"widget.collab.description" = "Wie er meegaat, de nieuwste berichten en de open peiling.";
|
||||
"widget.intent.title" = "Reis kiezen";
|
||||
"widget.intent.description" = "De reis die deze widget toont.";
|
||||
"widget.intent.trip" = "Reis";
|
||||
"widget.intent.auto" = "Automatisch";
|
||||
"widget.intent.autoHint" = "Dezelfde reis als het dashboard";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Podróż";
|
||||
"widget.trip.description" = "Odlicza dni do następnej podróży, a w trakcie pokazuje dzień i to, co dalej.";
|
||||
"widget.today.name" = "Dziś";
|
||||
"widget.today.description" = "Plan dnia jako oś czasu, z pogodą i kolejnym przystankiem.";
|
||||
"widget.bookings.name" = "Rezerwacje";
|
||||
"widget.bookings.description" = "Twoje najbliższe loty, pociągi, hotele i stoliki oraz ich terminy.";
|
||||
"widget.todos.name" = "Zadania";
|
||||
"widget.todos.description" = "Co jeszcze zostało do zrobienia przed podróżą i jak idzie pakowanie.";
|
||||
"widget.packing.name" = "Pakowanie";
|
||||
"widget.packing.description" = "Jak pełne są torby, ich waga i czego jeszcze brakuje.";
|
||||
"widget.atlas.name" = "Mapa świata";
|
||||
"widget.atlas.description" = "Kraje, w których byłeś, świecące na mapie.";
|
||||
"widget.collab.name" = "Grupa podróży";
|
||||
"widget.collab.description" = "Kto jedzie, najnowsze wiadomości i otwarta ankieta.";
|
||||
"widget.intent.title" = "Wybierz podróż";
|
||||
"widget.intent.description" = "Podróż, którą pokazuje ten widżet.";
|
||||
"widget.intent.trip" = "Podróż";
|
||||
"widget.intent.auto" = "Automatycznie";
|
||||
"widget.intent.autoHint" = "Ta sama podróż co na pulpicie";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Viagem";
|
||||
"widget.trip.description" = "Conta os dias até a próxima viagem e, durante ela, mostra o dia e o que vem a seguir.";
|
||||
"widget.today.name" = "Hoje";
|
||||
"widget.today.description" = "O plano do dia como linha do tempo, com o clima e a próxima parada.";
|
||||
"widget.bookings.name" = "Reservas";
|
||||
"widget.bookings.description" = "Seus próximos voos, trens, hotéis e mesas, e quando acontecem.";
|
||||
"widget.todos.name" = "Tarefas";
|
||||
"widget.todos.description" = "O que ainda falta para a viagem e quanto da mala já está pronto.";
|
||||
"widget.packing.name" = "Bagagem";
|
||||
"widget.packing.description" = "Quão cheias estão as malas, o peso e o que ainda falta.";
|
||||
"widget.atlas.name" = "Mapa-múndi";
|
||||
"widget.atlas.description" = "Os países em que você esteve, brilhando no mapa.";
|
||||
"widget.collab.name" = "Grupo de viagem";
|
||||
"widget.collab.description" = "Quem vai junto, as mensagens recentes e a votação aberta.";
|
||||
"widget.intent.title" = "Escolher viagem";
|
||||
"widget.intent.description" = "A viagem que este widget mostra.";
|
||||
"widget.intent.trip" = "Viagem";
|
||||
"widget.intent.auto" = "Automático";
|
||||
"widget.intent.autoHint" = "A mesma viagem do painel";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Поездка";
|
||||
"widget.trip.description" = "Отсчитывает дни до следующей поездки, а в пути показывает день и что дальше.";
|
||||
"widget.today.name" = "Сегодня";
|
||||
"widget.today.description" = "План дня в виде ленты времени, с погодой и следующей остановкой.";
|
||||
"widget.bookings.name" = "Бронирования";
|
||||
"widget.bookings.description" = "Ближайшие рейсы, поезда, отели и столики и когда они.";
|
||||
"widget.todos.name" = "Задачи";
|
||||
"widget.todos.description" = "Что ещё осталось сделать к поездке и как идут сборы.";
|
||||
"widget.packing.name" = "Сборы";
|
||||
"widget.packing.description" = "Насколько заполнены сумки, их вес и чего ещё не хватает.";
|
||||
"widget.atlas.name" = "Карта мира";
|
||||
"widget.atlas.description" = "Страны, где вы были, светятся на карте.";
|
||||
"widget.collab.name" = "Группа поездки";
|
||||
"widget.collab.description" = "Кто едет, последние сообщения и открытый опрос.";
|
||||
"widget.intent.title" = "Выбрать поездку";
|
||||
"widget.intent.description" = "Поездка, которую показывает этот виджет.";
|
||||
"widget.intent.trip" = "Поездка";
|
||||
"widget.intent.auto" = "Автоматически";
|
||||
"widget.intent.autoHint" = "Та же поездка, что и на главной";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Cesta";
|
||||
"widget.trip.description" = "Odpočítava dni do ďalšej cesty a počas nej ukazuje deň a čo príde ďalej.";
|
||||
"widget.today.name" = "Dnes";
|
||||
"widget.today.description" = "Plán dňa ako časová os, s počasím a ďalšou zastávkou.";
|
||||
"widget.bookings.name" = "Rezervácie";
|
||||
"widget.bookings.description" = "Tvoje ďalšie lety, vlaky, hotely a stoly a kedy ťa čakajú.";
|
||||
"widget.todos.name" = "Úlohy";
|
||||
"widget.todos.description" = "Čo ešte zostáva na cestu a ako ďaleko je balenie.";
|
||||
"widget.packing.name" = "Balenie";
|
||||
"widget.packing.description" = "Aké plné sú tašky, ich váha a čo ešte chýba.";
|
||||
"widget.atlas.name" = "Mapa sveta";
|
||||
"widget.atlas.description" = "Krajiny, kde si bol, žiariace na mape.";
|
||||
"widget.collab.name" = "Cestovná skupina";
|
||||
"widget.collab.description" = "Kto ide s tebou, najnovšie správy a otvorené hlasovanie.";
|
||||
"widget.intent.title" = "Vybrať cestu";
|
||||
"widget.intent.description" = "Cesta, ktorú zobrazuje tento widget.";
|
||||
"widget.intent.trip" = "Cesta";
|
||||
"widget.intent.auto" = "Automaticky";
|
||||
"widget.intent.autoHint" = "Rovnaká cesta ako na nástenke";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Resa";
|
||||
"widget.trip.description" = "Räknar ner till nästa resa och visar under resan dagen och vad som kommer härnäst.";
|
||||
"widget.today.name" = "Idag";
|
||||
"widget.today.description" = "Dagens plan som tidslinje, med vädret och nästa stopp.";
|
||||
"widget.bookings.name" = "Bokningar";
|
||||
"widget.bookings.description" = "Dina nästa flyg, tåg, hotell och bord, och när de är.";
|
||||
"widget.todos.name" = "Att göra";
|
||||
"widget.todos.description" = "Vad som återstår inför resan och hur långt packningen har kommit.";
|
||||
"widget.packing.name" = "Packning";
|
||||
"widget.packing.description" = "Hur fulla väskorna är, deras vikt och vad som fortfarande saknas.";
|
||||
"widget.atlas.name" = "Världskarta";
|
||||
"widget.atlas.description" = "Länderna du har besökt, lysande på kartan.";
|
||||
"widget.collab.name" = "Resesällskap";
|
||||
"widget.collab.description" = "Vem som följer med, de senaste meddelandena och den öppna omröstningen.";
|
||||
"widget.intent.title" = "Välj resa";
|
||||
"widget.intent.description" = "Resan som den här widgeten visar.";
|
||||
"widget.intent.trip" = "Resa";
|
||||
"widget.intent.auto" = "Automatiskt";
|
||||
"widget.intent.autoHint" = "Samma resa som på startsidan";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "ทริป";
|
||||
"widget.trip.description" = "นับถอยหลังสู่ทริปถัดไป และระหว่างทริปแสดงวันนี้และสิ่งที่จะตามมา";
|
||||
"widget.today.name" = "วันนี้";
|
||||
"widget.today.description" = "แผนของวันนี้เป็นไทม์ไลน์ พร้อมสภาพอากาศและจุดหมายถัดไป";
|
||||
"widget.bookings.name" = "การจอง";
|
||||
"widget.bookings.description" = "เที่ยวบิน รถไฟ โรงแรม และร้านอาหารถัดไปของคุณ และเวลาของแต่ละรายการ";
|
||||
"widget.todos.name" = "สิ่งที่ต้องทำ";
|
||||
"widget.todos.description" = "สิ่งที่ยังค้างอยู่สำหรับทริป และความคืบหน้าการจัดกระเป๋า";
|
||||
"widget.packing.name" = "จัดกระเป๋า";
|
||||
"widget.packing.description" = "กระเป๋าเต็มแค่ไหน น้ำหนัก และอะไรที่ยังขาด";
|
||||
"widget.atlas.name" = "แผนที่โลก";
|
||||
"widget.atlas.description" = "ประเทศที่คุณเคยไป ส่องแสงบนแผนที่";
|
||||
"widget.collab.name" = "กลุ่มเดินทาง";
|
||||
"widget.collab.description" = "ใครไปด้วย ข้อความล่าสุด และโหวตที่เปิดอยู่";
|
||||
"widget.intent.title" = "เลือกทริป";
|
||||
"widget.intent.description" = "ทริปที่วิดเจ็ตนี้แสดง";
|
||||
"widget.intent.trip" = "ทริป";
|
||||
"widget.intent.auto" = "อัตโนมัติ";
|
||||
"widget.intent.autoHint" = "ทริปเดียวกับแดชบอร์ด";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Seyahat";
|
||||
"widget.trip.description" = "Sonraki seyahate kalan günleri sayar, yolculukta günü ve sırada ne olduğunu gösterir.";
|
||||
"widget.today.name" = "Bugün";
|
||||
"widget.today.description" = "Günün planı zaman çizelgesi olarak, hava durumu ve sonraki durakla.";
|
||||
"widget.bookings.name" = "Rezervasyonlar";
|
||||
"widget.bookings.description" = "Sıradaki uçuşların, trenlerin, otellerin ve masaların, ne zaman oldukları ile.";
|
||||
"widget.todos.name" = "Yapılacaklar";
|
||||
"widget.todos.description" = "Seyahat için hâlâ açık olanlar ve bavulun ne durumda olduğu.";
|
||||
"widget.packing.name" = "Bavul";
|
||||
"widget.packing.description" = "Çantaların ne kadar dolu olduğu, ağırlıkları ve eksikler.";
|
||||
"widget.atlas.name" = "Dünya haritası";
|
||||
"widget.atlas.description" = "Bulunduğun ülkeler haritada parlıyor.";
|
||||
"widget.collab.name" = "Seyahat grubu";
|
||||
"widget.collab.description" = "Kimler geliyor, son mesajlar ve açık anket.";
|
||||
"widget.intent.title" = "Seyahat seç";
|
||||
"widget.intent.description" = "Bu widget'ın gösterdiği seyahat.";
|
||||
"widget.intent.trip" = "Seyahat";
|
||||
"widget.intent.auto" = "Otomatik";
|
||||
"widget.intent.autoHint" = "Paneldeki seyahatin aynısı";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Подорож";
|
||||
"widget.trip.description" = "Відлічує дні до наступної подорожі, а в дорозі показує день і що далі.";
|
||||
"widget.today.name" = "Сьогодні";
|
||||
"widget.today.description" = "План дня у вигляді стрічки часу, з погодою і наступною зупинкою.";
|
||||
"widget.bookings.name" = "Бронювання";
|
||||
"widget.bookings.description" = "Найближчі рейси, поїзди, готелі й столики та коли вони.";
|
||||
"widget.todos.name" = "Завдання";
|
||||
"widget.todos.description" = "Що ще лишилося зробити до подорожі і як просувається пакування.";
|
||||
"widget.packing.name" = "Пакування";
|
||||
"widget.packing.description" = "Наскільки заповнені сумки, їхня вага і чого ще бракує.";
|
||||
"widget.atlas.name" = "Мапа світу";
|
||||
"widget.atlas.description" = "Країни, де ви були, світяться на мапі.";
|
||||
"widget.collab.name" = "Група подорожі";
|
||||
"widget.collab.description" = "Хто їде, останні повідомлення і відкрите опитування.";
|
||||
"widget.intent.title" = "Вибрати подорож";
|
||||
"widget.intent.description" = "Подорож, яку показує цей віджет.";
|
||||
"widget.intent.trip" = "Подорож";
|
||||
"widget.intent.auto" = "Автоматично";
|
||||
"widget.intent.autoHint" = "Та сама подорож, що й на панелі";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Chuyến đi";
|
||||
"widget.trip.description" = "Đếm ngược đến chuyến đi tiếp theo, trong chuyến đi thì hiện ngày và điểm tiếp theo.";
|
||||
"widget.today.name" = "Hôm nay";
|
||||
"widget.today.description" = "Kế hoạch hôm nay dạng dòng thời gian, kèm thời tiết và điểm dừng tiếp theo.";
|
||||
"widget.bookings.name" = "Đặt chỗ";
|
||||
"widget.bookings.description" = "Các chuyến bay, tàu, khách sạn và bàn sắp tới của bạn, cùng thời gian.";
|
||||
"widget.todos.name" = "Việc cần làm";
|
||||
"widget.todos.description" = "Những gì còn lại cho chuyến đi và việc soạn hành lý đã tới đâu.";
|
||||
"widget.packing.name" = "Hành lý";
|
||||
"widget.packing.description" = "Túi đã đầy đến đâu, cân nặng và những gì còn thiếu.";
|
||||
"widget.atlas.name" = "Bản đồ thế giới";
|
||||
"widget.atlas.description" = "Những quốc gia bạn đã đến, sáng lên trên bản đồ.";
|
||||
"widget.collab.name" = "Nhóm du lịch";
|
||||
"widget.collab.description" = "Ai đi cùng, tin nhắn mới nhất và cuộc bình chọn đang mở.";
|
||||
"widget.intent.title" = "Chọn chuyến đi";
|
||||
"widget.intent.description" = "Chuyến đi mà tiện ích này hiển thị.";
|
||||
"widget.intent.trip" = "Chuyến đi";
|
||||
"widget.intent.auto" = "Tự động";
|
||||
"widget.intent.autoHint" = "Cùng chuyến đi với bảng điều khiển";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "旅行";
|
||||
"widget.trip.description" = "倒数到下一次旅行,旅途中显示当天行程和接下来的安排。";
|
||||
"widget.today.name" = "今天";
|
||||
"widget.today.description" = "以时间线显示今天的计划,附带天气和下一站。";
|
||||
"widget.bookings.name" = "预订";
|
||||
"widget.bookings.description" = "你接下来的航班、火车、酒店和餐厅,以及时间。";
|
||||
"widget.todos.name" = "待办";
|
||||
"widget.todos.description" = "旅行还剩哪些事没做,以及行李收拾到哪一步。";
|
||||
"widget.packing.name" = "行李";
|
||||
"widget.packing.description" = "包装得多满、重量多少,以及还缺什么。";
|
||||
"widget.atlas.name" = "世界地图";
|
||||
"widget.atlas.description" = "你去过的国家在地图上亮起。";
|
||||
"widget.collab.name" = "旅行小组";
|
||||
"widget.collab.description" = "谁一起去、最新消息和进行中的投票。";
|
||||
"widget.intent.title" = "选择旅行";
|
||||
"widget.intent.description" = "此小组件显示的旅行。";
|
||||
"widget.intent.trip" = "旅行";
|
||||
"widget.intent.auto" = "自动";
|
||||
"widget.intent.autoHint" = "与仪表板相同的旅行";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "旅行";
|
||||
"widget.trip.description" = "倒數到下一次旅行,旅途中顯示當天行程與接下來的安排。";
|
||||
"widget.today.name" = "今天";
|
||||
"widget.today.description" = "以時間軸顯示今天的計畫,附上天氣與下一站。";
|
||||
"widget.bookings.name" = "預訂";
|
||||
"widget.bookings.description" = "你接下來的航班、火車、飯店與餐廳,以及時間。";
|
||||
"widget.todos.name" = "待辦";
|
||||
"widget.todos.description" = "旅行還剩哪些事沒做,以及行李整理到哪一步。";
|
||||
"widget.packing.name" = "行李";
|
||||
"widget.packing.description" = "包包裝得多滿、重量多少,以及還缺什麼。";
|
||||
"widget.atlas.name" = "世界地圖";
|
||||
"widget.atlas.description" = "你去過的國家在地圖上亮起。";
|
||||
"widget.collab.name" = "旅行小組";
|
||||
"widget.collab.description" = "誰一起去、最新訊息與進行中的投票。";
|
||||
"widget.intent.title" = "選擇旅行";
|
||||
"widget.intent.description" = "此小工具顯示的旅行。";
|
||||
"widget.intent.trip" = "旅行";
|
||||
"widget.intent.auto" = "自動";
|
||||
"widget.intent.autoHint" = "與儀表板相同的旅行";
|
||||
@@ -0,0 +1,3 @@
|
||||
Sources/WidgetPreviews/Widgets/
|
||||
.build/
|
||||
out/
|
||||
@@ -0,0 +1,14 @@
|
||||
// swift-tools-version:5.9
|
||||
import PackageDescription
|
||||
|
||||
// Renders the widget views (client/ios/App/TREKWidgets/*Content.swift and the
|
||||
// files they use) to PNGs on a Mac, so a change to a widget can be looked at
|
||||
// without a phone: .github/workflows/native.yml copies them into Sources/ and
|
||||
// uploads what this writes. Not part of the app.
|
||||
let package = Package(
|
||||
name: "WidgetPreviews",
|
||||
platforms: [.macOS(.v14)],
|
||||
targets: [
|
||||
.executableTarget(name: "WidgetPreviews", path: "Sources/WidgetPreviews"),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,388 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Home and lock screens of an iPhone filled with the widgets, at the size of
|
||||
/// an iPhone 16 Pro (402 by 874 points), for showing what the widgets look like
|
||||
/// where they live. The app icon comes from the asset catalogue.
|
||||
enum Mockups {
|
||||
@MainActor
|
||||
static func render(to out: URL, iconPath: String?, now: Date) throws {
|
||||
let icon = iconPath.flatMap { NSImage(contentsOfFile: $0) }.map { Image(nsImage: $0) }
|
||||
var upcoming = WidgetSnapshot.sample(running: false, now: now)
|
||||
upcoming.trip?.cover = Render.sampleCover()
|
||||
var running = WidgetSnapshot.sample(running: true, now: now)
|
||||
running.trip?.cover = upcoming.trip?.cover
|
||||
|
||||
let pages: [(String, ColorScheme, AnyView)] = [
|
||||
("1-travelling", .dark, AnyView(PhonePage(wallpaper: .night, icon: icon, page: 0) {
|
||||
WidgetFrame(kind: .trip, family: .large, snapshot: running, now: now)
|
||||
WidgetFrame(kind: .today, family: .medium, snapshot: running, now: now)
|
||||
AppRow(icon: icon, apps: [.maps, .photos, .camera, .weather])
|
||||
})),
|
||||
("2-countdown", .light, AnyView(PhonePage(wallpaper: .dawn, icon: icon, page: 1) {
|
||||
WidgetFrame(kind: .trip, family: .medium, snapshot: upcoming, now: now)
|
||||
HStack(spacing: 24) {
|
||||
WidgetFrame(kind: .bookings, family: .small, snapshot: upcoming, now: now)
|
||||
WidgetFrame(kind: .packing, family: .small, snapshot: upcoming, now: now)
|
||||
}
|
||||
WidgetFrame(kind: .todos, family: .medium, snapshot: upcoming, now: now)
|
||||
AppRow(icon: icon, apps: [.calendar, .trek, .mail, .notes])
|
||||
})),
|
||||
("3-explorer", .dark, AnyView(PhonePage(wallpaper: .ocean, icon: icon, page: 2) {
|
||||
WidgetFrame(kind: .atlas, family: .large, snapshot: upcoming, now: now)
|
||||
WidgetFrame(kind: .collab, family: .medium, snapshot: running, now: now)
|
||||
AppRow(icon: icon, apps: [.photos, .trek, .music, .settings])
|
||||
})),
|
||||
("4-getting-ready", .light, AnyView(PhonePage(wallpaper: .sand, icon: icon, page: 3) {
|
||||
WidgetFrame(kind: .packing, family: .large, snapshot: upcoming, now: now)
|
||||
HStack(spacing: 24) {
|
||||
WidgetFrame(kind: .todos, family: .small, snapshot: upcoming, now: now)
|
||||
WidgetFrame(kind: .atlas, family: .small, snapshot: upcoming, now: now)
|
||||
}
|
||||
AppRow(icon: icon, apps: [.wallet, .trek, .maps, .clock])
|
||||
})),
|
||||
("5-on-the-road", .dark, AnyView(PhonePage(wallpaper: .night, icon: icon, page: 4) {
|
||||
HStack(spacing: 24) {
|
||||
WidgetFrame(kind: .trip, family: .small, snapshot: running, now: now)
|
||||
WidgetFrame(kind: .collab, family: .small, snapshot: running, now: now)
|
||||
}
|
||||
WidgetFrame(kind: .bookings, family: .medium, snapshot: running, now: now)
|
||||
HStack(spacing: 24) {
|
||||
WidgetFrame(kind: .bookings, family: .small, snapshot: running, now: now)
|
||||
WidgetFrame(kind: .atlas, family: .small, snapshot: running, now: now)
|
||||
}
|
||||
AppRow(icon: icon, apps: [.maps, .trek, .camera, .weather])
|
||||
})),
|
||||
("6-lock-screen", .dark, AnyView(LockPage(snapshot: running, now: now))),
|
||||
]
|
||||
for (name, scheme, view) in pages {
|
||||
try Render.write(
|
||||
view.environment(\.colorScheme, scheme).environment(\.trekStaticPreview, true),
|
||||
to: out.appendingPathComponent("mockup-\(name).png")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The phone
|
||||
|
||||
enum Wallpaper {
|
||||
case night, dawn, ocean, sand
|
||||
|
||||
@ViewBuilder var view: some View {
|
||||
switch self {
|
||||
case .night:
|
||||
ZStack {
|
||||
LinearGradient(colors: [Color(hex: 0x0E1230), Color(hex: 0x1B1340), Color(hex: 0x05060C)], startPoint: .top, endPoint: .bottom)
|
||||
RadialGradient(colors: [Color(hex: 0x6A3FD8).opacity(0.75), .clear], center: UnitPoint(x: 0.15, y: 0.25), startRadius: 0, endRadius: 360)
|
||||
RadialGradient(colors: [Color(hex: 0x1E8FD8).opacity(0.6), .clear], center: UnitPoint(x: 0.9, y: 0.7), startRadius: 0, endRadius: 380)
|
||||
}
|
||||
case .dawn:
|
||||
ZStack {
|
||||
LinearGradient(colors: [Color(hex: 0xF6D5C3), Color(hex: 0xE9B8C9), Color(hex: 0xB9C6EE)], startPoint: .top, endPoint: .bottom)
|
||||
RadialGradient(colors: [Color(hex: 0xFFE2A8).opacity(0.9), .clear], center: UnitPoint(x: 0.8, y: 0.15), startRadius: 0, endRadius: 320)
|
||||
RadialGradient(colors: [Color(hex: 0x8FA8F0).opacity(0.7), .clear], center: UnitPoint(x: 0.1, y: 0.85), startRadius: 0, endRadius: 360)
|
||||
}
|
||||
case .ocean:
|
||||
ZStack {
|
||||
LinearGradient(colors: [Color(hex: 0x03263A), Color(hex: 0x064D5C), Color(hex: 0x021018)], startPoint: .topLeading, endPoint: .bottomTrailing)
|
||||
RadialGradient(colors: [Color(hex: 0x22C7A9).opacity(0.55), .clear], center: UnitPoint(x: 0.85, y: 0.2), startRadius: 0, endRadius: 340)
|
||||
RadialGradient(colors: [Color(hex: 0x2E5BD8).opacity(0.55), .clear], center: UnitPoint(x: 0.1, y: 0.75), startRadius: 0, endRadius: 360)
|
||||
}
|
||||
case .sand:
|
||||
ZStack {
|
||||
LinearGradient(colors: [Color(hex: 0xEFE6D8), Color(hex: 0xE3D2BC), Color(hex: 0xCFD9D4)], startPoint: .top, endPoint: .bottom)
|
||||
RadialGradient(colors: [Color(hex: 0xF4B183).opacity(0.65), .clear], center: UnitPoint(x: 0.2, y: 0.2), startRadius: 0, endRadius: 330)
|
||||
RadialGradient(colors: [Color(hex: 0x9CC5B8).opacity(0.7), .clear], center: UnitPoint(x: 0.9, y: 0.8), startRadius: 0, endRadius: 360)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One page of the home screen: status bar, the widgets and apps, the page
|
||||
/// dots and the dock, on the wallpaper, with the screen's rounded corners.
|
||||
struct PhonePage<Content: View>: View {
|
||||
let wallpaper: Wallpaper
|
||||
let icon: Image?
|
||||
let page: Int
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .top) {
|
||||
wallpaper.view
|
||||
VStack(spacing: 0) {
|
||||
StatusBar(dark: wallpaper == .night || wallpaper == .ocean)
|
||||
VStack(spacing: 22) { content() }
|
||||
.padding(.top, 14)
|
||||
Spacer(minLength: 0)
|
||||
PageDots(count: 5, current: page)
|
||||
.padding(.bottom, 12)
|
||||
Dock(icon: icon)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 14)
|
||||
}
|
||||
}
|
||||
.frame(width: 402, height: 874)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 56, style: .continuous))
|
||||
.padding(30)
|
||||
.background(Color(hex: 0x111214))
|
||||
}
|
||||
}
|
||||
|
||||
struct StatusBar: View {
|
||||
let dark: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text("9:41").font(.system(size: 17, weight: .semibold))
|
||||
.frame(width: 120)
|
||||
Spacer()
|
||||
Capsule().fill(.black).frame(width: 124, height: 36)
|
||||
Spacer()
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "cellularbars")
|
||||
Image(systemName: "wifi")
|
||||
Image(systemName: "battery.100percent")
|
||||
}
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.frame(width: 120)
|
||||
}
|
||||
.foregroundStyle(dark ? .white : .black)
|
||||
.padding(.top, 12)
|
||||
.frame(height: 58)
|
||||
}
|
||||
}
|
||||
|
||||
struct PageDots: View {
|
||||
let count: Int
|
||||
let current: Int
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(0..<count, id: \.self) { index in
|
||||
Circle().fill(Color.white.opacity(index == current ? 0.95 : 0.4)).frame(width: 7, height: 7)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.background(Capsule().fill(Color.black.opacity(0.18)))
|
||||
}
|
||||
}
|
||||
|
||||
/// A generic app, drawn as a symbol on a gradient, never a real app's icon.
|
||||
enum MockApp {
|
||||
case trek, maps, photos, camera, weather, calendar, mail, notes, music, settings, wallet, clock, phone, messages, browser
|
||||
|
||||
var name: String {
|
||||
switch self {
|
||||
case .trek: return "TREK"
|
||||
case .maps: return "Maps"
|
||||
case .photos: return "Photos"
|
||||
case .camera: return "Camera"
|
||||
case .weather: return "Weather"
|
||||
case .calendar: return "Calendar"
|
||||
case .mail: return "Mail"
|
||||
case .notes: return "Notes"
|
||||
case .music: return "Music"
|
||||
case .settings: return "Settings"
|
||||
case .wallet: return "Wallet"
|
||||
case .clock: return "Clock"
|
||||
case .phone: return "Phone"
|
||||
case .messages: return "Messages"
|
||||
case .browser: return "Browser"
|
||||
}
|
||||
}
|
||||
|
||||
var look: (String, [Color], Color) {
|
||||
switch self {
|
||||
case .trek: return ("map.fill", [Color(hex: 0x202024), Color(hex: 0x0C0C0F)], .white)
|
||||
case .maps: return ("map", [Color(hex: 0x6FD08C), Color(hex: 0x2FA37A)], .white)
|
||||
case .photos: return ("camera.macro", [Color(hex: 0xFFFFFF), Color(hex: 0xF2F2F2)], Color(hex: 0xE8A13A))
|
||||
case .camera: return ("camera.fill", [Color(hex: 0xC8C8CC), Color(hex: 0x8E8E93)], Color(hex: 0x2B2B2E))
|
||||
case .weather: return ("cloud.sun.fill", [Color(hex: 0x5AB3F5), Color(hex: 0x2E6FD8)], .white)
|
||||
case .calendar: return ("calendar", [Color(hex: 0xFFFFFF), Color(hex: 0xF4F4F4)], Color(hex: 0xE5484D))
|
||||
case .mail: return ("envelope.fill", [Color(hex: 0x56B4FF), Color(hex: 0x1D7AF2)], .white)
|
||||
case .notes: return ("note.text", [Color(hex: 0xFFE27A), Color(hex: 0xF5C542)], Color(hex: 0x5A4A12))
|
||||
case .music: return ("music.note", [Color(hex: 0xFF6B81), Color(hex: 0xF0304F)], .white)
|
||||
case .settings: return ("gearshape.fill", [Color(hex: 0xA6A6AB), Color(hex: 0x6E6E73)], .white)
|
||||
case .wallet: return ("wallet.pass.fill", [Color(hex: 0x2B2B2E), Color(hex: 0x111113)], Color(hex: 0x7BC96F))
|
||||
case .clock: return ("clock.fill", [Color(hex: 0x2B2B2E), Color(hex: 0x111113)], .white)
|
||||
case .phone: return ("phone.fill", [Color(hex: 0x6FE38A), Color(hex: 0x34C759)], .white)
|
||||
case .messages: return ("message.fill", [Color(hex: 0x6FE38A), Color(hex: 0x34C759)], .white)
|
||||
case .browser: return ("safari.fill", [Color(hex: 0x56B4FF), Color(hex: 0x1D7AF2)], .white)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AppIcon: View {
|
||||
let app: MockApp
|
||||
let icon: Image?
|
||||
var label = true
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 5) {
|
||||
Group {
|
||||
if app == .trek, let icon {
|
||||
icon.resizable().scaledToFill()
|
||||
} else {
|
||||
let (symbol, colors, ink) = app.look
|
||||
LinearGradient(colors: colors, startPoint: .top, endPoint: .bottom)
|
||||
.overlay(Image(systemName: symbol).font(.system(size: 28, weight: .medium)).foregroundStyle(ink))
|
||||
}
|
||||
}
|
||||
.frame(width: 64, height: 64)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 15, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.18), radius: 3, y: 1)
|
||||
if label {
|
||||
Text(app.name).font(.system(size: 12, weight: .medium)).foregroundStyle(.white).shadow(color: .black.opacity(0.35), radius: 2)
|
||||
}
|
||||
}
|
||||
.frame(width: 80)
|
||||
}
|
||||
}
|
||||
|
||||
struct AppRow: View {
|
||||
let icon: Image?
|
||||
let apps: [MockApp]
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 13) {
|
||||
ForEach(Array(apps.enumerated()), id: \.offset) { _, app in AppIcon(app: app, icon: icon) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Dock: View {
|
||||
let icon: Image?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 13) {
|
||||
ForEach([MockApp.phone, .browser, .messages, .trek], id: \.name) { app in
|
||||
AppIcon(app: app, icon: icon, label: false)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 14)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(RoundedRectangle(cornerRadius: 38, style: .continuous).fill(Color.white.opacity(0.22)))
|
||||
.overlay(RoundedRectangle(cornerRadius: 38, style: .continuous).stroke(Color.white.opacity(0.25), lineWidth: 0.6))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The lock screen
|
||||
|
||||
struct LockPage: View {
|
||||
let snapshot: WidgetSnapshot
|
||||
let now: Date
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
StatusBar(dark: true)
|
||||
Image(systemName: "lock.fill").font(.system(size: 18, weight: .semibold)).foregroundStyle(.white).padding(.top, 6)
|
||||
LockAccessory(kind: .trip, family: .inline, snapshot: snapshot, now: now)
|
||||
.padding(.top, 10)
|
||||
Text("9:41")
|
||||
.font(.system(size: 104, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.92))
|
||||
.padding(.top, -6)
|
||||
HStack(spacing: 14) {
|
||||
LockAccessory(kind: .trip, family: .circular, snapshot: snapshot, now: now)
|
||||
LockAccessory(kind: .packing, family: .circular, snapshot: snapshot, now: now)
|
||||
LockAccessory(kind: .bookings, family: .rectangular, snapshot: snapshot, now: now)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
Spacer(minLength: 0)
|
||||
LiveActivityMock(snapshot: snapshot, now: now)
|
||||
.padding(.horizontal, 12)
|
||||
HStack {
|
||||
LockButton(symbol: "flashlight.off.fill")
|
||||
Spacer()
|
||||
LockButton(symbol: "camera.fill")
|
||||
}
|
||||
.padding(.horizontal, 46)
|
||||
.padding(.top, 22)
|
||||
Capsule().fill(.white).frame(width: 140, height: 5).padding(.top, 20).padding(.bottom, 10)
|
||||
}
|
||||
.frame(width: 402, height: 874)
|
||||
// Behind, not inside: a photo filled into a stack would widen it past the screen.
|
||||
.background(TripBackdrop(trip: snapshot.trip, shade: 0.35).frame(width: 402, height: 874).clipped())
|
||||
.clipShape(RoundedRectangle(cornerRadius: 56, style: .continuous))
|
||||
.padding(30)
|
||||
.background(Color(hex: 0x111214))
|
||||
}
|
||||
}
|
||||
|
||||
private struct LockButton: View {
|
||||
let symbol: String
|
||||
|
||||
var body: some View {
|
||||
Circle().fill(Color.black.opacity(0.35)).frame(width: 50, height: 50)
|
||||
.overlay(Image(systemName: symbol).font(.system(size: 19)).foregroundStyle(.white))
|
||||
}
|
||||
}
|
||||
|
||||
/// The travel day's Live Activity as the lock screen shows it (the real one is
|
||||
/// TripDayLiveActivity, which needs ActivityKit).
|
||||
struct LiveActivityMock: View {
|
||||
let snapshot: WidgetSnapshot
|
||||
let now: Date
|
||||
|
||||
var body: some View {
|
||||
let today = snapshot.today
|
||||
let minutes = Calendar.current.component(.hour, from: now) * 60 + Calendar.current.component(.minute, from: now)
|
||||
let stops = today?.stops ?? []
|
||||
let next = stops.first { ($0.minutes ?? -1) >= minutes } ?? stops.last
|
||||
let done = stops.filter { ($0.minutes ?? Int.max) < minutes }.count
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
Circle().stroke(Color.white.opacity(0.18), lineWidth: 5)
|
||||
Circle().trim(from: 0, to: stops.isEmpty ? 0 : Double(done) / Double(stops.count))
|
||||
.stroke(Trek.confirmed, style: StrokeStyle(lineWidth: 5, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
Image(systemName: "mappin.and.ellipse").font(.system(size: 17, weight: .bold)).foregroundStyle(.white)
|
||||
}
|
||||
.frame(width: 52, height: 52)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack {
|
||||
Text(snapshot.trip?.title ?? "").font(.system(size: 11, weight: .bold)).tracking(0.5).textCase(.uppercase).foregroundStyle(.white.opacity(0.65)).lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
Text(today?.dayLabel ?? "").font(.system(size: 11, weight: .bold)).foregroundStyle(.white.opacity(0.65))
|
||||
}
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(next?.name ?? "").font(.system(size: 18, weight: .heavy)).foregroundStyle(.white).lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
Text(next?.time ?? "").font(Trek.number(18, .heavy)).foregroundStyle(.white)
|
||||
}
|
||||
DaySegments(index: done + 1, total: max(stops.count, 1), color: Trek.confirmed, track: Color.white.opacity(0.2))
|
||||
.padding(.top, 3)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(RoundedRectangle(cornerRadius: 26, style: .continuous).fill(Color(hex: 0x0C0C0F).opacity(0.78)))
|
||||
}
|
||||
}
|
||||
|
||||
/// A lock-screen accessory as the system draws it: white on the wallpaper,
|
||||
/// the circular ones on a soft disc, no box of their own.
|
||||
struct LockAccessory: View {
|
||||
let kind: WidgetKind
|
||||
let family: TrekFamily
|
||||
let snapshot: WidgetSnapshot
|
||||
let now: Date
|
||||
|
||||
var body: some View {
|
||||
let size = family.previewSize
|
||||
Group {
|
||||
switch kind {
|
||||
case .trip: TripContent(snapshot: snapshot, family: family, now: now)
|
||||
case .bookings: BookingsContent(snapshot: snapshot, family: family, now: now)
|
||||
case .todos: TodosContent(snapshot: snapshot, family: family, now: now)
|
||||
case .packing: PackingContent(snapshot: snapshot, family: family, now: now)
|
||||
case .atlas: AtlasContent(snapshot: snapshot, family: family)
|
||||
default: EmptyView()
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: size.width, height: size.height)
|
||||
.background(Circle().fill(Color.white.opacity(family == .circular ? 0.16 : 0)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Draws every widget in every size, light and dark, for a trip ahead, a trip
|
||||
/// under way and no trip, plus a home screen of them on a wallpaper per scheme.
|
||||
/// Usage: WidgetPreviews <output directory>
|
||||
@main
|
||||
struct Render {
|
||||
@MainActor
|
||||
static func main() throws {
|
||||
let out = URL(fileURLWithPath: CommandLine.arguments.dropFirst().first ?? "out", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: out, withIntermediateDirectories: true)
|
||||
// A fixed afternoon, so the now line sits between the stops.
|
||||
var components = Calendar.current.dateComponents([.year, .month, .day], from: Date())
|
||||
components.hour = 12
|
||||
components.minute = 10
|
||||
let now = Calendar.current.date(from: components) ?? Date()
|
||||
|
||||
var withCover = WidgetSnapshot.sample(running: false, now: now)
|
||||
withCover.trip?.cover = sampleCover()
|
||||
var runningCover = WidgetSnapshot.sample(running: true, now: now)
|
||||
runningCover.trip?.cover = withCover.trip?.cover
|
||||
let states: [(String, WidgetSnapshot)] = [
|
||||
("upcoming", .sample(running: false, now: now)),
|
||||
("running", .sample(running: true, now: now)),
|
||||
("empty", .sampleEmpty),
|
||||
("cover", withCover),
|
||||
("coverrunning", runningCover),
|
||||
]
|
||||
for scheme in [ColorScheme.light, .dark] {
|
||||
let schemeName = scheme == .dark ? "dark" : "light"
|
||||
for (stateName, snapshot) in states {
|
||||
for kind in WidgetKind.allCases {
|
||||
for family in kind.families {
|
||||
let view = WidgetFrame(kind: kind, family: family, snapshot: snapshot, now: now)
|
||||
.environment(\.colorScheme, scheme)
|
||||
.environment(\.trekStaticPreview, true)
|
||||
try write(view, to: out.appendingPathComponent("\(kind.rawValue)-\(family.rawValue)-\(stateName)-\(schemeName).png"))
|
||||
}
|
||||
}
|
||||
}
|
||||
let showcase = NewWidgets(snapshot: states[0].1, now: now)
|
||||
.environment(\.colorScheme, scheme)
|
||||
.environment(\.trekStaticPreview, true)
|
||||
try write(showcase, to: out.appendingPathComponent("new-widgets-\(schemeName).png"))
|
||||
for (stateName, snapshot) in states where stateName != "empty" {
|
||||
let sheet = HomeScreen(snapshot: snapshot, now: now)
|
||||
.environment(\.colorScheme, scheme)
|
||||
.environment(\.trekStaticPreview, true)
|
||||
try write(sheet, to: out.appendingPathComponent("home-\(stateName)-\(schemeName).png"))
|
||||
}
|
||||
let lock = LockScreen(snapshots: states.prefix(3).map(\.1), now: now).environment(\.colorScheme, scheme)
|
||||
try write(lock, to: out.appendingPathComponent("lock-\(schemeName).png"))
|
||||
}
|
||||
let args = Array(CommandLine.arguments.dropFirst())
|
||||
try Mockups.render(to: out, iconPath: args.count > 1 ? args[1] : nil, now: now)
|
||||
print("wrote \(try FileManager.default.contentsOfDirectory(atPath: out.path).count) images to \(out.path)")
|
||||
}
|
||||
|
||||
/// A stand-in for a trip's cover photo: dusk over layered mountains, as the
|
||||
/// small JPEG the app would send.
|
||||
@MainActor
|
||||
static func sampleCover() -> String? {
|
||||
let scene = ZStack(alignment: .bottom) {
|
||||
LinearGradient(colors: [Color(hex: 0x2B3A67), Color(hex: 0xC0617A), Color(hex: 0xF3A65A)], startPoint: .top, endPoint: .bottom)
|
||||
Circle().fill(Color(hex: 0xFFE2A8)).frame(width: 120, height: 120).offset(x: 140, y: -170).blur(radius: 2)
|
||||
Ridge(height: 0.55, seed: 3).fill(Color(hex: 0x6B4A6E))
|
||||
Ridge(height: 0.42, seed: 7).fill(Color(hex: 0x3E2E4F))
|
||||
Ridge(height: 0.26, seed: 11).fill(Color(hex: 0x1C1726))
|
||||
}
|
||||
.frame(width: 720, height: 480)
|
||||
let renderer = ImageRenderer(content: scene)
|
||||
renderer.scale = 1
|
||||
guard let image = renderer.nsImage, let tiff = image.tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiff),
|
||||
let jpeg = bitmap.representation(using: .jpeg, properties: [.compressionFactor: 0.74]) else { return nil }
|
||||
return jpeg.base64EncodedString()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func write<V: View>(_ view: V, to url: URL) throws {
|
||||
let renderer = ImageRenderer(content: view)
|
||||
renderer.scale = 3
|
||||
guard let image = renderer.nsImage,
|
||||
let tiff = image.tiffRepresentation,
|
||||
let bitmap = NSBitmapImageRep(data: tiff),
|
||||
let png = bitmap.representation(using: .png, properties: [:]) else {
|
||||
throw NSError(domain: "WidgetPreviews", code: 1, userInfo: [NSLocalizedDescriptionKey: "could not render \(url.lastPathComponent)"])
|
||||
}
|
||||
try png.write(to: url)
|
||||
}
|
||||
}
|
||||
|
||||
enum WidgetKind: String, CaseIterable {
|
||||
case trip, today, bookings, todos, packing, atlas, collab
|
||||
|
||||
var families: [TrekFamily] {
|
||||
switch self {
|
||||
case .trip: return [.small, .medium, .large, .circular, .rectangular, .inline]
|
||||
case .today: return [.medium, .large]
|
||||
case .bookings: return [.small, .medium, .large, .rectangular, .inline]
|
||||
case .todos: return [.small, .medium, .large, .circular, .rectangular]
|
||||
case .packing: return [.small, .medium, .large, .circular, .rectangular]
|
||||
case .atlas: return [.small, .medium, .large, .circular, .rectangular, .inline]
|
||||
case .collab: return [.small, .medium, .large, .rectangular]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One widget as the home screen draws it: its background, the 16 pt content
|
||||
/// margin, the rounded corners; accessories white on a lock-screen tint.
|
||||
struct WidgetFrame: View {
|
||||
let kind: WidgetKind
|
||||
let family: TrekFamily
|
||||
let snapshot: WidgetSnapshot
|
||||
let now: Date
|
||||
|
||||
var body: some View {
|
||||
let size = family.previewSize
|
||||
if family.isAccessory {
|
||||
// As the lock screen draws them: white on the wallpaper, no box of their own
|
||||
// (the circular one gets the system's soft disc).
|
||||
content
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: size.width, height: size.height)
|
||||
.background(Circle().fill(Color.white.opacity(family == .circular ? 0.14 : 0)))
|
||||
.padding(6)
|
||||
.background(Color(hex: 0x1E2A3A))
|
||||
} else {
|
||||
content
|
||||
.padding(16)
|
||||
.frame(width: size.width, height: size.height)
|
||||
.background(background)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
switch kind {
|
||||
case .trip: TripContent(snapshot: snapshot, family: family, now: now)
|
||||
case .today: TodayContent(snapshot: snapshot, family: family, now: now)
|
||||
case .bookings: BookingsContent(snapshot: snapshot, family: family, now: now)
|
||||
case .todos: TodosContent(snapshot: snapshot, family: family, now: now)
|
||||
case .packing: PackingContent(snapshot: snapshot, family: family, now: now)
|
||||
case .atlas: AtlasContent(snapshot: snapshot, family: family)
|
||||
case .collab: CollabContent(snapshot: snapshot, family: family)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var background: some View {
|
||||
if kind == .trip && snapshot.trip != nil {
|
||||
TripBackdrop(trip: snapshot.trip)
|
||||
} else {
|
||||
TrekPaper(contours: kind != .atlas)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A home screen of the widgets on an iPhone-sized wallpaper.
|
||||
struct HomeScreen: View {
|
||||
let snapshot: WidgetSnapshot
|
||||
let now: Date
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 22) {
|
||||
HStack(spacing: 22) {
|
||||
WidgetFrame(kind: .trip, family: .small, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .todos, family: .small, snapshot: snapshot, now: now)
|
||||
}
|
||||
WidgetFrame(kind: snapshot.today == nil ? .trip : .today, family: .medium, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .bookings, family: .medium, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .trip, family: .large, snapshot: snapshot, now: now)
|
||||
HStack(spacing: 22) {
|
||||
WidgetFrame(kind: .bookings, family: .small, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .todos, family: .small, snapshot: snapshot, now: now)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 60)
|
||||
.padding(.horizontal, 24)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: scheme == .dark ? [Color(hex: 0x1D2440), Color(hex: 0x0B0F1E)] : [Color(hex: 0xC9D8F0), Color(hex: 0xF3E3D3)],
|
||||
startPoint: .top, endPoint: .bottom
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The lock-screen accessories in a row per state.
|
||||
struct LockScreen: View {
|
||||
let snapshots: [WidgetSnapshot]
|
||||
let now: Date
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
ForEach(Array(snapshots.enumerated()), id: \.offset) { _, snapshot in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
WidgetFrame(kind: .trip, family: .inline, snapshot: snapshot, now: now)
|
||||
HStack(spacing: 10) {
|
||||
WidgetFrame(kind: .trip, family: .circular, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .todos, family: .circular, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .trip, family: .rectangular, snapshot: snapshot, now: now)
|
||||
}
|
||||
HStack(spacing: 10) {
|
||||
WidgetFrame(kind: .bookings, family: .rectangular, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .todos, family: .rectangular, snapshot: snapshot, now: now)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.background(Color(hex: 0x1E2A3A))
|
||||
}
|
||||
}
|
||||
|
||||
/// A mountain ridge across the bottom of its frame.
|
||||
struct Ridge: Shape {
|
||||
let height: CGFloat
|
||||
let seed: Int
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: rect.minX, y: rect.maxY))
|
||||
let steps = 48
|
||||
for step in 0...steps {
|
||||
let x: CGFloat = rect.minX + rect.width * CGFloat(step) / CGFloat(steps)
|
||||
let phase: CGFloat = CGFloat(step) * 0.35 + CGFloat(seed)
|
||||
let wave: CGFloat = sin(phase) * 0.06 + sin(phase * 2.7) * 0.035
|
||||
let y: CGFloat = rect.maxY - rect.height * (height + wave)
|
||||
path.addLine(to: CGPoint(x: x, y: y))
|
||||
}
|
||||
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
|
||||
path.closeSubpath()
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
/// The packing, atlas and group widgets side by side, the sheet to judge them by.
|
||||
struct NewWidgets: View {
|
||||
let snapshot: WidgetSnapshot
|
||||
let now: Date
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 22) {
|
||||
HStack(spacing: 22) {
|
||||
WidgetFrame(kind: .packing, family: .small, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .atlas, family: .small, snapshot: snapshot, now: now)
|
||||
}
|
||||
WidgetFrame(kind: .atlas, family: .medium, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .packing, family: .medium, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .collab, family: .medium, snapshot: snapshot, now: now)
|
||||
HStack(spacing: 22) {
|
||||
WidgetFrame(kind: .collab, family: .small, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .todos, family: .small, snapshot: snapshot, now: now)
|
||||
}
|
||||
WidgetFrame(kind: .atlas, family: .large, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .packing, family: .large, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .collab, family: .large, snapshot: snapshot, now: now)
|
||||
}
|
||||
.padding(.vertical, 60)
|
||||
.padding(.horizontal, 24)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: scheme == .dark ? [Color(hex: 0x1D2440), Color(hex: 0x0B0F1E)] : [Color(hex: 0xC9D8F0), Color(hex: 0xF3E3D3)],
|
||||
startPoint: .top, endPoint: .bottom
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Writes client/ios/App/TREKWidgets/WorldGrid.swift: the world as a grid of dots,
|
||||
each with the ISO code of the country under it, for the atlas widget.
|
||||
|
||||
Run from the repo root after the country shapes change:
|
||||
python client/ios/WidgetPreviews/make-world-grid.py
|
||||
Reads server/assets/atlas/admin0.geojson.gz, the same shapes the Atlas page draws.
|
||||
"""
|
||||
import gzip
|
||||
import json
|
||||
|
||||
COLS, ROWS = 76, 34
|
||||
LAT_TOP, LAT_BOTTOM = 80.0, -58.0 # Antarctica and the far north stay off the map
|
||||
SOURCE = 'server/assets/atlas/admin0.geojson.gz'
|
||||
TARGET = 'client/ios/App/TREKWidgets/WorldGrid.swift'
|
||||
|
||||
|
||||
def rings(geometry):
|
||||
if geometry['type'] == 'Polygon':
|
||||
yield geometry['coordinates']
|
||||
elif geometry['type'] == 'MultiPolygon':
|
||||
yield from geometry['coordinates']
|
||||
|
||||
|
||||
def inside(ring, x, y):
|
||||
hit = False
|
||||
j = len(ring) - 1
|
||||
for i in range(len(ring)):
|
||||
xi, yi = ring[i][0], ring[i][1]
|
||||
xj, yj = ring[j][0], ring[j][1]
|
||||
if (yi > y) != (yj > y) and x < (xj - xi) * (y - yi) / ((yj - yi) or 1e-12) + xi:
|
||||
hit = not hit
|
||||
j = i
|
||||
return hit
|
||||
|
||||
|
||||
def in_polygon(polygon, x, y):
|
||||
return inside(polygon[0], x, y) and not any(inside(hole, x, y) for hole in polygon[1:])
|
||||
|
||||
|
||||
features = []
|
||||
for feature in json.load(gzip.open(SOURCE))['features']:
|
||||
code = feature['properties'].get('ISO_A2') or ''
|
||||
if len(code) != 2 or not code.isalpha():
|
||||
continue
|
||||
for polygon in rings(feature['geometry']):
|
||||
xs = [p[0] for p in polygon[0]]
|
||||
ys = [p[1] for p in polygon[0]]
|
||||
features.append((code, (min(xs), min(ys), max(xs), max(ys)), polygon))
|
||||
|
||||
# Each cell is sampled at five points, so a narrow country still claims its dot.
|
||||
offsets = [(0.5, 0.5), (0.25, 0.25), (0.75, 0.25), (0.25, 0.75), (0.75, 0.75)]
|
||||
cell_w = 360.0 / COLS
|
||||
cell_h = (LAT_TOP - LAT_BOTTOM) / ROWS
|
||||
rows = []
|
||||
for r in range(ROWS):
|
||||
row = []
|
||||
for c in range(COLS):
|
||||
votes = {}
|
||||
for ox, oy in offsets:
|
||||
x = -180 + (c + ox) * cell_w
|
||||
y = LAT_TOP - (r + oy) * cell_h
|
||||
for code, (x0, y0, x1, y1), polygon in features:
|
||||
if x0 <= x <= x1 and y0 <= y <= y1 and in_polygon(polygon, x, y):
|
||||
votes[code] = votes.get(code, 0) + 1
|
||||
break
|
||||
row.append(max(votes, key=votes.get) if votes else '..')
|
||||
rows.append(''.join(row))
|
||||
|
||||
land = sum(len(r) // 2 - r.count('..') for r in rows)
|
||||
body = '\n'.join(f' "{r}",' for r in rows)
|
||||
swift = f'''// Generated by client/ios/WidgetPreviews/make-world-grid.py from
|
||||
// server/assets/atlas/admin0.geojson.gz; do not edit by hand.
|
||||
import Foundation
|
||||
|
||||
/// The world as {COLS} by {ROWS} dots from {LAT_TOP:g}° north to {-LAT_BOTTOM:g}° south,
|
||||
/// each the ISO code of the country under it, ".." for sea ({land} land dots).
|
||||
enum WorldGrid {{
|
||||
static let columns = {COLS}
|
||||
static let rows: [String] = [
|
||||
{body}
|
||||
]
|
||||
|
||||
/// The country code of a dot, nil for sea.
|
||||
static func code(row: Int, column: Int) -> String? {{
|
||||
let line = rows[row]
|
||||
let start = line.index(line.startIndex, offsetBy: column * 2)
|
||||
let code = String(line[start..<line.index(start, offsetBy: 2)])
|
||||
return code == ".." ? nil : code
|
||||
}}
|
||||
}}
|
||||
'''
|
||||
open(TARGET, 'w', encoding='utf8', newline='\n').write(swift)
|
||||
print('land dots', land)
|
||||
@@ -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 |
+188
-65
@@ -3,8 +3,10 @@ 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 { getIapClient, productIdFor, type Purchase } from '../src/native/iap';
|
||||
import { CLOUD_URL, PRIVACY_URL, SERVER_DOCS_URL } from '../src/native/links';
|
||||
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 { fakeIapClient, getIapClient, productIdFor, type Purchase } from '../src/native/iap';
|
||||
import { CLOUD_URL, PRIVACY_URL, SERVER_DOCS_URL, STORE_TERMS_URL } from '../src/native/links';
|
||||
import { probeDemo, probeServer, type ProbeFailure, type ProbeGet } from '../src/native/serverAddress';
|
||||
import { TrekShell } from '../src/native/trekShell';
|
||||
import { pickLocale, translator } from './i18n';
|
||||
@@ -26,8 +28,12 @@ const root = document.getElementById('shell')!;
|
||||
const PROBE_TIMEOUT_MS = 8000;
|
||||
|
||||
const iap = getIapClient();
|
||||
/** Whether this build can take a payment in-app; decided once at start. Until then, and in a store build without the purchase plugin, the plan picker finishes on the web. */
|
||||
let iapAvailable = false;
|
||||
/**
|
||||
* Whether this build can take a payment in-app, asked once at start. Without it
|
||||
* the plan picker shows the plans but offers no way to buy: the store rules
|
||||
* allow no link or button that sends a buyer to the website instead.
|
||||
*/
|
||||
const iapReady: Promise<boolean> = iap.isAvailable().catch(() => false);
|
||||
|
||||
/** The subdomain suffix a cloud instance sits on, from the cloud address: `liketrek.com` → `.liketrek.com`. */
|
||||
function cloudSuffix(): string {
|
||||
@@ -147,11 +153,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 +179,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 +193,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 +201,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 +260,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 +268,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 +303,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,67 +340,149 @@ 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 {
|
||||
void tap(ImpactStyle.Medium);
|
||||
// In-app purchase when this build can take one; otherwise finish on the web, so
|
||||
// nothing half-wired faces a customer until the store products exist.
|
||||
if (iapAvailable) {
|
||||
showClaim(plan, interval);
|
||||
} else {
|
||||
window.open(`${CLOUD_URL}/order?plan=${encodeURIComponent(plan.id)}`, '_blank', 'noopener');
|
||||
}
|
||||
/**
|
||||
* What the plan picker can sell. With the store, a plan is offered at the price
|
||||
* string the App Store gives for its product, in the buyer's currency, and only
|
||||
* when the store knows that product. The fake (development, preview) sells
|
||||
* every plan at the plan list's price. Without either, nothing is for sale.
|
||||
*/
|
||||
interface Offer {
|
||||
canBuy: boolean;
|
||||
storePrices: Map<string, string> | null;
|
||||
}
|
||||
|
||||
function planCard(plan: CloudPlan, interval: 'month' | 'year', featured: boolean): HTMLElement {
|
||||
const price = interval === 'year' ? plan.yearlyCents : plan.monthlyCents;
|
||||
function offerFor(offer: Offer, plan: CloudPlan, interval: 'month' | 'year'): { label: string; buyable: boolean } {
|
||||
const cents = interval === 'year' ? plan.yearlyCents : plan.monthlyCents;
|
||||
const storePrice = offer.storePrices?.get(productIdFor(plan.id, interval));
|
||||
if (offer.storePrices) return { label: storePrice ?? money(cents), buyable: offer.canBuy && !!storePrice };
|
||||
return { label: money(cents), buyable: offer.canBuy };
|
||||
}
|
||||
|
||||
async function loadOffer(plans: CloudPlan[]): Promise<Offer> {
|
||||
const canBuy = await iapReady;
|
||||
if (!canBuy || iap === fakeIapClient) return { canBuy, storePrices: null };
|
||||
const ids = plans.flatMap((p) => [productIdFor(p.id, 'month'), productIdFor(p.id, 'year')]);
|
||||
return { canBuy, storePrices: await iap.prices(ids) };
|
||||
}
|
||||
|
||||
function planCard(plan: CloudPlan, interval: 'month' | 'year', offer: Offer): HTMLElement {
|
||||
const { label, buyable } = offerFor(offer, plan, interval);
|
||||
const per = interval === 'year' ? t('native.plans.perYear') : t('native.plans.perMonth');
|
||||
const feat = (text: string) => el('span', {}, icon(I.check, 16), text);
|
||||
const sub = el('button', { type: 'button', className: 'btn' }, t('native.plans.subscribe'));
|
||||
sub.addEventListener('click', () => subscribe(plan, interval));
|
||||
// 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);
|
||||
let sub: HTMLElement | null = null;
|
||||
if (buyable) {
|
||||
sub = el('button', { type: 'button', className: 'btn' }, t('native.plans.subscribe'));
|
||||
sub.addEventListener('click', () => {
|
||||
void tap(ImpactStyle.Medium);
|
||||
showClaim(plan, interval, label);
|
||||
});
|
||||
}
|
||||
return el(
|
||||
'div',
|
||||
{ className: featured ? 'plan featured' : 'plan' },
|
||||
{ className: 'plan' },
|
||||
el(
|
||||
'div',
|
||||
{ className: 'plan-top' },
|
||||
el('span', { className: 'plan-name' }, plan.name),
|
||||
el('span', { className: 'plan-price' }, money(price), el('small', {}, per))
|
||||
el('span', { className: 'plan-price' }, label, el('small', {}, per))
|
||||
),
|
||||
el(
|
||||
'div',
|
||||
{ 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
|
||||
...(sub ? [sub] : [])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Below the plans. With a store: how the subscription renews, the terms and the
|
||||
* privacy policy, and a way to restore a subscription this Apple ID already
|
||||
* holds (a reinstall, a new phone). Without one: only that buying in the app is
|
||||
* not available, and no pointer elsewhere.
|
||||
*/
|
||||
function planFooter(offer: Offer): Node[] {
|
||||
if (!offer.canBuy) return [el('p', { className: 'note' }, t('native.plans.soon'))];
|
||||
|
||||
const status = el('p', { className: 'hint', role: 'status' });
|
||||
const restore = el('button', { type: 'button', className: 'btn btn-ghost' }, t('native.plans.restore'));
|
||||
restore.addEventListener('click', async () => {
|
||||
void tap();
|
||||
restore.disabled = true;
|
||||
status.className = 'hint';
|
||||
status.textContent = t('native.plans.restoring');
|
||||
const purchase = await iap.restore();
|
||||
if (!purchase) {
|
||||
restore.disabled = false;
|
||||
status.textContent = t('native.plans.restoreNone');
|
||||
return;
|
||||
}
|
||||
// A restore finds the instance the purchase was first redeemed for; address and email are not asked again.
|
||||
const redeemed = await redeemPurchase('', '', purchase);
|
||||
if (redeemed.ok && redeemed.instanceUrl) {
|
||||
await iap.finish(purchase);
|
||||
showProvisioning(redeemed.instanceUrl);
|
||||
return;
|
||||
}
|
||||
void notify(NotificationType.Error);
|
||||
restore.disabled = false;
|
||||
status.className = 'hint bad';
|
||||
status.textContent = t('native.plans.restoreFailed');
|
||||
});
|
||||
|
||||
return [
|
||||
restore,
|
||||
status,
|
||||
el('p', { className: 'note' }, t('native.plans.legal')),
|
||||
el(
|
||||
'div',
|
||||
{ className: 'foot-links' },
|
||||
externalLink(t('native.plans.terms'), STORE_TERMS_URL),
|
||||
externalLink(t('native.privacy'), PRIVACY_URL)
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function showPlans(opts: { back?: boolean } = {}): void {
|
||||
let interval: 'month' | 'year' = 'month';
|
||||
let plans: CloudPlan[] | null = null;
|
||||
let offer: Offer = { canBuy: false, storePrices: null };
|
||||
|
||||
const content = el('div', { className: 'loading' }, el('span', { className: 'spinner' }));
|
||||
const footer = el('div', { className: 'plans-foot' });
|
||||
|
||||
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 +493,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, offer)));
|
||||
};
|
||||
const choose = (next: 'month' | 'year') => {
|
||||
if (interval === next) return;
|
||||
@@ -411,9 +507,12 @@ function showPlans(opts: { back?: boolean } = {}): void {
|
||||
paintSeg();
|
||||
|
||||
const load = async () => {
|
||||
plans = await fetchPlans();
|
||||
if (plans) {
|
||||
const list = await fetchPlans();
|
||||
if (list) {
|
||||
offer = await loadOffer(list);
|
||||
plans = list;
|
||||
renderList();
|
||||
footer.replaceChildren(...planFooter(offer));
|
||||
return;
|
||||
}
|
||||
content.className = '';
|
||||
@@ -436,7 +535,7 @@ function showPlans(opts: { back?: boolean } = {}): void {
|
||||
el('p', { className: 'sub' }, t('native.plans.sub')),
|
||||
seg,
|
||||
content,
|
||||
el('p', { className: 'note' }, t('native.plans.soon'))
|
||||
footer
|
||||
),
|
||||
opts
|
||||
);
|
||||
@@ -485,8 +584,9 @@ async function redeemPurchase(slug: string, email: string, purchase: Purchase):
|
||||
}
|
||||
}
|
||||
|
||||
function showClaim(plan: CloudPlan, interval: 'month' | 'year', opts: { back?: boolean } = {}): void {
|
||||
const price = interval === 'year' ? plan.yearlyCents : plan.monthlyCents;
|
||||
function showClaim(plan: CloudPlan, interval: 'month' | 'year', price: string, opts: { back?: boolean } = {}): void {
|
||||
/** A purchase that went through but whose redeem did not: a retry redeems it again instead of buying twice. */
|
||||
let bought: Purchase | null = null;
|
||||
|
||||
const slugInput = el('input', {
|
||||
type: 'text',
|
||||
@@ -494,6 +594,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 +608,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');
|
||||
@@ -517,7 +624,7 @@ function showClaim(plan: CloudPlan, interval: 'month' | 'year', opts: { back?: b
|
||||
const submit = el(
|
||||
'button',
|
||||
{ type: 'submit', className: 'btn' },
|
||||
t('native.claim.subscribe', { price: money(price) })
|
||||
t('native.claim.subscribe', { price })
|
||||
);
|
||||
const error = el('p', { className: 'error', role: 'alert' });
|
||||
const form = el(
|
||||
@@ -588,24 +695,33 @@ function showClaim(plan: CloudPlan, interval: 'month' | 'year', opts: { back?: b
|
||||
submit.replaceChildren(el('span', { className: 'spinner' }), t('native.connect.checking'));
|
||||
const restore = () => {
|
||||
lock(false);
|
||||
submit.replaceChildren(t('native.claim.subscribe', { price: money(price) }));
|
||||
submit.replaceChildren(t('native.claim.subscribe', { price }));
|
||||
};
|
||||
|
||||
const outcome = await iap.purchase(productIdFor(plan.id, interval));
|
||||
if (outcome.kind === 'cancelled') {
|
||||
restore();
|
||||
error.textContent = t('native.claim.cancelled');
|
||||
return;
|
||||
}
|
||||
if (outcome.kind === 'failed') {
|
||||
void notify(NotificationType.Error);
|
||||
restore();
|
||||
error.textContent = t('native.claim.failed');
|
||||
return;
|
||||
if (!bought) {
|
||||
const outcome = await iap.purchase(productIdFor(plan.id, interval));
|
||||
if (outcome.kind === 'cancelled') {
|
||||
restore();
|
||||
error.textContent = t('native.claim.cancelled');
|
||||
return;
|
||||
}
|
||||
if (outcome.kind === 'pending') {
|
||||
restore();
|
||||
error.textContent = t('native.claim.pending');
|
||||
return;
|
||||
}
|
||||
if (outcome.kind === 'failed') {
|
||||
void notify(NotificationType.Error);
|
||||
restore();
|
||||
error.textContent = t('native.claim.failed');
|
||||
return;
|
||||
}
|
||||
bought = outcome.purchase;
|
||||
}
|
||||
|
||||
const redeemed = await redeemPurchase(slug, email, outcome.purchase);
|
||||
const redeemed = await redeemPurchase(slug, email, bought);
|
||||
if (redeemed.ok && redeemed.instanceUrl) {
|
||||
await iap.finish(bought);
|
||||
showProvisioning(redeemed.instanceUrl);
|
||||
return;
|
||||
}
|
||||
@@ -779,10 +895,17 @@ async function paintStatusBar(): Promise<void> {
|
||||
|
||||
async function start(): Promise<void> {
|
||||
void paintStatusBar();
|
||||
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 {
|
||||
|
||||
+293
-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;
|
||||
@@ -652,23 +881,60 @@ a.linkbtn {
|
||||
margin: 2px 0 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
/* Under the plans: restore, the renewal terms, the legal links. */
|
||||
.plans-foot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.plans-foot .hint {
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
.plans-foot .hint:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── 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 {
|
||||
|
||||
+3
-3
@@ -143,7 +143,7 @@
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"@vitest/coverage-v8": "^4.1.12",
|
||||
"autoprefixer": "^10.6.1",
|
||||
"eslint": "^10.11.0",
|
||||
"eslint-config-flat-gitignore": "^2.4.0",
|
||||
@@ -162,8 +162,8 @@
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.70.0",
|
||||
"vite": "8.3.0",
|
||||
"vite": "8.3.5",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vitest": "^4.1.11"
|
||||
"vitest": "^4.1.12"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"src": {
|
||||
"@typescript-eslint/no-non-null-assertion": 377,
|
||||
"@typescript-eslint/no-non-null-assertion": 375,
|
||||
"@typescript-eslint/no-misused-promises": 847,
|
||||
"no-console": 88,
|
||||
"complexity": 218,
|
||||
"@typescript-eslint/no-explicit-any": 144,
|
||||
"trek/no-swallowed-catch": 128,
|
||||
"max-params": 32,
|
||||
"max-params": 31,
|
||||
"trek/store-without-selector": 67,
|
||||
"react-hooks/refs": 554,
|
||||
"react-hooks/set-state-in-effect": 193,
|
||||
"react-hooks/refs": 553,
|
||||
"react-hooks/set-state-in-effect": 192,
|
||||
"@typescript-eslint/no-floating-promises": 164,
|
||||
"react-hooks/exhaustive-deps": 116,
|
||||
"@typescript-eslint/no-unused-vars": 124,
|
||||
"react-hooks/exhaustive-deps": 114,
|
||||
"@typescript-eslint/no-unused-vars": 123,
|
||||
"react-hooks/immutability": 28,
|
||||
"max-lines-per-function": 130,
|
||||
"react-hooks/static-components": 50,
|
||||
@@ -21,7 +21,7 @@
|
||||
"no-empty": 19,
|
||||
"trek/no-window-globals": 41,
|
||||
"react-refresh/only-export-components": 44,
|
||||
"unused-disable-directive": 6,
|
||||
"unused-disable-directive": 5,
|
||||
"max-depth": 21,
|
||||
"react-hooks/void-use-memo": 1,
|
||||
"@typescript-eslint/no-unused-expressions": 2,
|
||||
@@ -31,11 +31,11 @@
|
||||
},
|
||||
"suppressed": {
|
||||
"@typescript-eslint/no-explicit-any": 38,
|
||||
"react-hooks/exhaustive-deps": 46
|
||||
"react-hooks/exhaustive-deps": 45
|
||||
},
|
||||
"tests": {
|
||||
"@typescript-eslint/no-explicit-any": 489,
|
||||
"@typescript-eslint/no-non-null-assertion": 1914,
|
||||
"@typescript-eslint/no-non-null-assertion": 1912,
|
||||
"@typescript-eslint/no-misused-promises": 22,
|
||||
"@typescript-eslint/no-unused-vars": 51,
|
||||
"@typescript-eslint/no-floating-promises": 33,
|
||||
|
||||
@@ -620,8 +620,7 @@
|
||||
"desktop": ["src/components/Planner/PlacesSidebarHeader.tsx", "src/components/Planner/usePlacesSidebar.ts"],
|
||||
"mobile": [
|
||||
"src/mobile/screens/trip/places/MPlacesBrowser.tsx",
|
||||
"src/mobile/screens/trip/places/MPlacesFilterControls.tsx",
|
||||
"src/mobile/screens/trip/places/MPlacesFilterSheet.tsx"
|
||||
"src/mobile/screens/trip/places/MPlacesFilterControls.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -670,6 +669,12 @@
|
||||
"desktop": ["src/components/Roadtrip/RoadtripStayModal.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/roadtrip/MRtStaySheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Demo welcome",
|
||||
"hooks": ["src/components/Demo/useDemoWelcome.ts"],
|
||||
"desktop": ["src/components/Demo/DemoWelcome.tsx"],
|
||||
"mobile": ["src/mobile/screens/dashboard/MDemoWelcome.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Settings / Demo account locks",
|
||||
"hooks": ["src/components/Demo/useDemoLock.ts"],
|
||||
@@ -1002,6 +1007,7 @@
|
||||
"src/mobile/screens/collections/collectionsMobileModel.ts": "Visual constants only the phone collections screens use (category and status specs, swatches, cover gradient).",
|
||||
"src/mobile/screens/dashboard/MUserMenu.tsx": "Phone avatar menu with a three state theme cycle the desktop navbar does not have.",
|
||||
"src/mobile/screens/journey/MDawarichDaySheet.tsx": "Markup only; the phone frame the Dawarich day sheets (route and stays) render their shared review in.",
|
||||
"src/mobile/screens/settings/MAppLockCard.tsx": "Phone only: the app lock exists only in the native app, so there is no desktop setting to share with.",
|
||||
"src/mobile/screens/settings/MConfirmSheet.tsx": "Phone only bottom sheet confirm dialog used across the mobile screens.",
|
||||
"src/mobile/screens/settings/MDawarichConnectionSection.tsx": "Already shares hooks/useDawarichConnection with components/Settings/DawarichConnectionSection.tsx.",
|
||||
"src/mobile/screens/settings/MMobileDashOrder.tsx": "Phone only setting that orders the mobile dashboard blocks; the desktop has no such setting.",
|
||||
@@ -1010,17 +1016,18 @@
|
||||
"src/mobile/screens/settings/MSettingsAbout.tsx": "Markup only about card with static links and no state.",
|
||||
"src/mobile/screens/settings/MSettingsOffline.tsx": "Already shares components/Settings/useOfflineSettings with components/Settings/OfflineTab.tsx.",
|
||||
"src/mobile/screens/settings/MSettingsUi.tsx": "Phone only settings UI primitives (card, row, input, button, segments, hint).",
|
||||
"src/mobile/screens/settings/MTileCacheUsage.tsx": "Phone only: the native tile cache size and clear action, which the browser build has no counterpart for.",
|
||||
"src/mobile/screens/settings/MWebPushCard.tsx": "Already shares hooks/useWebPush with components/Settings/WebPushCard.tsx.",
|
||||
"src/mobile/screens/trip/dockTabs.ts": "Phone only helper that picks which trip tabs get a seat in the mobile dock.",
|
||||
"src/mobile/screens/trip/lib/dayNotes.ts": "Phone only helper that splits a leading time off a day note for the plan timeline.",
|
||||
"src/mobile/screens/trip/map/MMapArea.tsx": "Phone map stage framing; already shares usePoiExplore and useMergedMapPois with the desktop trip planner.",
|
||||
"src/mobile/screens/trip/map/MPlacesFilterPill.tsx": "Phone only map control for the active places filter count, built on the shared MapTogglePill.",
|
||||
"src/mobile/screens/trip/places/MPlacesBulkCategorySheet.tsx": "Markup only; categories and the pick handler come through props from MPlacesBrowser.",
|
||||
"src/mobile/screens/trip/places/MPlacesToursModeSwitch.tsx": "Markup only places or tours toggle; value and onChange come through props from MPlacesBrowser.",
|
||||
"src/mobile/screens/trip/places/placesBrowserModel.ts": "Phone pool helpers over the shared utils/placesFilter, placeSearch and plannedPlaces.",
|
||||
"src/mobile/screens/trip/plan/MPlanTimelineRows.tsx": "Markup only timeline rows; row data comes from MPlanTimeline and planTimelineModel.",
|
||||
"src/mobile/screens/trip/plan/MRouteAppsSheet.tsx": "Markup only; the route apps come from the shared components/Planner/dayRouteApps the desktop menu uses too.",
|
||||
"src/mobile/screens/trip/plan/openRouteTarget.ts": "Phone only helper that opens a route target: a web link in a new tab, geo: in place.",
|
||||
"src/mobile/screens/trip/plan/planTimelineModel.ts": "Phone plan timeline row builder over the shared utils/dayMerge, dayOrder and dayTourProjection.",
|
||||
"src/mobile/screens/trip/plan/useMPlanDaySwipe.ts": "Phone only touch swipe gesture for switching days.",
|
||||
"src/mobile/screens/trip/plan/useMPlanDragReorder.ts": "Phone only long press drag gesture; the reorder itself runs through the handler it is given.",
|
||||
"src/mobile/screens/trip/roadtrip/MRoadtripRows.tsx": "Markup only road trip rows over the shared Roadtrip models the desktop sidebar rows also use.",
|
||||
"src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx": "Phone road trip tab over the shared roadtripRowModel, stopBookings and nightBookend RoadtripSidebar uses.",
|
||||
@@ -1039,10 +1046,12 @@
|
||||
"src/mobile/screens/trip/sheets/MBrowseActionsSheet.tsx": "Phone action sheet for a pool place that calls planner handlers and the shared placeToSaveTarget.",
|
||||
"src/mobile/screens/trip/sheets/MDaysSheet.tsx": "Button based day reorder sheet over planner.handleReorderDays; the desktop reorders by drag.",
|
||||
"src/mobile/screens/trip/sheets/MLinkedCosts.tsx": "Already shares useExpenseLinks with the desktop BookingCostsSection; only markup and a search filter are local.",
|
||||
"src/mobile/screens/trip/sheets/MTourDetailShell.tsx": "Markup only phone frame for the shared TourDetailDialog, which renders its own content into it.",
|
||||
"src/mobile/screens/trip/sheets/MTripSheetUi.tsx": "Phone sheet UI primitives (eyebrow, headers, stat boxes, action circles) and a time display helper.",
|
||||
"src/mobile/screens/trip/sheets/PlFileAttach.tsx": "Markup only; pending files and link actions come through props from the phone form sheets.",
|
||||
"src/mobile/screens/trip/sheets/PlSheetChrome.tsx": "Phone form sheet chrome primitives (header, footer, field classes).",
|
||||
"src/mobile/screens/trip/sheets/PlTimeFields.tsx": "Phone time fields that already share timeCollisions with the desktop PlaceFormModal.",
|
||||
"src/mobile/screens/trip/sheets/tourSheetOpen.ts": "Phone only context for MTourDetailShell: whether the tour sheet is open and the name it carries.",
|
||||
"src/mobile/screens/trip/tabs/MCollabTab.tsx": "Phone collab sub tab router that reuses the desktop CollabLinks component.",
|
||||
"src/mobile/screens/trip/tabs/MListsTab.tsx": "Phone router between the packing and to-do bodies, with no logic of its own.",
|
||||
"src/mobile/screens/trip/tabs/MReceiptScanButton.tsx": "Already shares useReceiptScan and ReceiptScanModal with the desktop CostsPanel.",
|
||||
|
||||
@@ -1351,11 +1351,9 @@
|
||||
"src/mobile/screens/trip/dockTabs.ts",
|
||||
"src/mobile/screens/trip/lib/dayNotes.ts",
|
||||
"src/mobile/screens/trip/map/MMapArea.tsx",
|
||||
"src/mobile/screens/trip/map/MPlacesFilterPill.tsx",
|
||||
"src/mobile/screens/trip/places/MPlacesBrowser.tsx",
|
||||
"src/mobile/screens/trip/places/MPlacesBulkCategorySheet.tsx",
|
||||
"src/mobile/screens/trip/places/MPlacesFilterControls.tsx",
|
||||
"src/mobile/screens/trip/places/MPlacesFilterSheet.tsx",
|
||||
"src/mobile/screens/trip/places/MPlacesSaveToCollectionSheet.tsx",
|
||||
"src/mobile/screens/trip/places/MPlacesToursModeSwitch.tsx",
|
||||
"src/mobile/screens/trip/places/MToursSelectionList.test.tsx",
|
||||
@@ -1366,8 +1364,6 @@
|
||||
"src/mobile/screens/trip/plan/MPlanTimelineRows.tsx",
|
||||
"src/mobile/screens/trip/plan/planTimelineModel.test.ts",
|
||||
"src/mobile/screens/trip/plan/planTimelineModel.ts",
|
||||
"src/mobile/screens/trip/plan/useMPlanDaySwipe.test.tsx",
|
||||
"src/mobile/screens/trip/plan/useMPlanDaySwipe.ts",
|
||||
"src/mobile/screens/trip/plan/useMPlanDragReorder.test.ts",
|
||||
"src/mobile/screens/trip/plan/useMPlanDragReorder.ts",
|
||||
"src/mobile/screens/trip/plan/useMPlanTimeline.ts",
|
||||
@@ -1982,7 +1978,6 @@
|
||||
"tests/unit/mobile/trip/MPlaceSheet.test.tsx",
|
||||
"tests/unit/mobile/trip/MPlacesBrowser.test.tsx",
|
||||
"tests/unit/mobile/trip/MPlacesBulkCategorySheet.test.tsx",
|
||||
"tests/unit/mobile/trip/MPlacesFilterSheet.test.tsx",
|
||||
"tests/unit/mobile/trip/MPlacesSaveToCollectionSheet.test.tsx",
|
||||
"tests/unit/mobile/trip/MPlanTimeline.test.tsx",
|
||||
"tests/unit/mobile/trip/MPlanTimelineRows.test.tsx",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"collab.ts": 9,
|
||||
"collection.ts": 35,
|
||||
"common.ts": 7,
|
||||
"dashboard.ts": 65,
|
||||
"dashboard.ts": 64,
|
||||
"dawarich.ts": 15,
|
||||
"dayplan.ts": 17,
|
||||
"docsync.ts": 25,
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
"help/contexts/tripMap.ts": 1,
|
||||
"hooks/useAnchoredPosition.ts": 3,
|
||||
"hooks/useElementRect.ts": 3,
|
||||
"mobile/components/MSegmented.tsx": 3,
|
||||
"mobile/components/MSegmented.tsx": 1,
|
||||
"mobile/components/MSheet.tsx": 1,
|
||||
"mobile/screens/dashboard/MDashboard.tsx": 1,
|
||||
"mobile/screens/journey/MJourneyDetail.tsx": 3,
|
||||
|
||||
@@ -4,5 +4,7 @@
|
||||
"components/Studio/StudioSelectionChrome.tsx": 2,
|
||||
"components/Studio/inspector/FocalPad.tsx": 1,
|
||||
"mobile/components/trek3d/MascotClip.tsx": 1,
|
||||
"mobile/screens/trip/MTripShell.tsx": 1,
|
||||
"mobile/screens/vacay/MVacay.tsx": 1,
|
||||
"pages/tripPlanner/PlanMapStage.tsx": 4
|
||||
}
|
||||
|
||||
@@ -5,18 +5,18 @@
|
||||
"src/components/Admin/AdminPluginsPanel.tsx": 1266,
|
||||
"src/components/Budget/CostsPanel.test.tsx": 2730,
|
||||
"src/components/Budget/CostsPanel.tsx": 1798,
|
||||
"src/components/Map/MapView.tsx": 1337,
|
||||
"src/components/Map/MapView.tsx": 1214,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2919,
|
||||
"src/components/Map/MapViewGL.tsx": 2198,
|
||||
"src/components/Map/MapViewGL.tsx": 2127,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2929,
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
|
||||
"src/components/Planner/PlaceInspector.tsx": 1290,
|
||||
"src/components/Planner/PlaceInspector.tsx": 1287,
|
||||
"src/components/Studio/TravelElements.tsx": 1559,
|
||||
"src/components/Studio/countryShapes.ts": 2595,
|
||||
"src/index.css": 1636,
|
||||
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
|
||||
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1506,
|
||||
"src/pages/JourneyDetailPage.test.tsx": 4028,
|
||||
"src/pages/LoginPage.tsx": 1347,
|
||||
"src/pages/TripPlannerPage.tsx": 1128,
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"src/components/Journey/journeyGeo.ts": 4,
|
||||
"src/components/Layout/Navbar.test.tsx": 1,
|
||||
"src/components/Map/MapView.test.tsx": 2,
|
||||
"src/components/Map/MapView.tsx": 8,
|
||||
"src/components/Map/MapView.tsx": 7,
|
||||
"src/components/Map/MapViewGL.tsx": 1,
|
||||
"src/components/Map/ReservationOverlay.test.tsx": 1,
|
||||
"src/components/Map/transitGeometry.ts": 1,
|
||||
@@ -105,7 +105,7 @@
|
||||
"src/mobile/screens/trip/map/MMapArea.tsx": 1,
|
||||
"src/mobile/screens/trip/places/MPlacesBrowser.tsx": 2,
|
||||
"src/mobile/screens/trip/plan/planTimelineModel.ts": 1,
|
||||
"src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx": 2,
|
||||
"src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx": 1,
|
||||
"src/mobile/screens/trip/roadtrip/MRtAlternativesBar.tsx": 1,
|
||||
"src/mobile/screens/trip/roadtrip/MRtCorridorBar.tsx": 1,
|
||||
"src/mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx": 2,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,8 +19,9 @@
|
||||
"components/Journey/journeyPhotoLayer.ts": 2,
|
||||
"components/Map/DawarichTrailLayer.tsx": 1,
|
||||
"components/Map/dawarichTrail.ts": 10,
|
||||
"components/Map/markerLook.ts": 1,
|
||||
"components/Map/useDawarichTrailGL.ts": 1,
|
||||
"components/Packing/PackingListPanelItemRow.tsx": 1,
|
||||
"components/Packing/PackingBringersCard.tsx": 1,
|
||||
"components/Planner/DayReorderPopup.tsx": 1,
|
||||
"components/Planner/bookings/bookingsModel.ts": 1,
|
||||
"components/Planner/bookings/transitParts.tsx": 1,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user