Compare commits

...

10 Commits

Author SHA1 Message Date
Maurice fe7847fc08 feat(native): pick the trip a widget shows in Edit Widget
Widgets followed the dashboard's trip only, which is the one under way, so a
packing list or to-dos for a trip still being planned could not sit on the
home screen. The trip, today, bookings, to-dos, packing and group widgets now
take a trip in Edit Widget (an AppIntent with the trips running or ahead,
Automatic by default, which keeps following the dashboard); the world map
needs none.

- The app writes a snapshot per trip running or ahead (at most six, the
  dashboard's order) and the list to pick from, under their own keys
  (feature widgetTrips, so an older binary never mistakes one for the
  dashboard's); bags, chat and polls are kept ten minutes, covers per trip
- The widget extension targets iOS 17, which configurable widgets need; the
  apps are not released, so no installed widget goes away
- The choices and their texts in the 28 languages of the gallery
2026-11-06 21:41:21 +01:00
Maurice 77b9252577 fix(journey): the card under the finger grows while swiping, and the zoom buttons really go on touch screens
- The entry carousel draws the card at the centre larger while the swipe is
  still moving (focusIndex, a transform), and the active card and the map
  follow 90 ms after it stops instead of 150; the grow takes 200 ms
- The journey map's zoom buttons set their display inline, which beat the
  class that hides them on touch screens; the display is a class now (#3001)
- The packing widget data no longer carries a colour literal
2026-11-06 21:30:27 +01:00
Maurice 3364c0d131 chore(native): the lock screen mockup without the preview's boxes, the photo kept to the screen 2026-11-06 21:03:27 +01:00
Maurice deabaf7398 chore(native): home and lock screen mockups of the widgets in the preview renderer 2026-11-06 21:03:27 +01:00
Maurice 9b780d6b7e fix(native): whole initials in the group avatars, a smooth glow across the world map 2026-11-06 21:03:26 +01:00
Maurice 93727084ec feat(native): packing, world map and travel group widgets
- Packing: a ring of what is in the bags, each bag against its weight limit,
  the items still missing in their bag's colour, a nudge when departure is
  two days or less away
- World map: the world as dots from the Atlas country shapes
  (WorldGrid.swift, generated by WidgetPreviews/make-world-grid.py), the
  visited countries glowing, the latest one ringed, continents as bars and
  the latest countries with their flags; it needs no trip
- Travel group: who comes along as avatars, the latest chat messages as
  bubbles and the open poll with its votes as bars
- The to-dos widget is about to-dos only; the packing ring moved out

The app sends the bags, the Atlas countries (an hour's cache) and the chat
and poll with the snapshot; all optional, as before.
2026-11-06 21:03:26 +01:00
Maurice ed178f3b5e fix(journey): the shared journey map reaches the screen edge, and desktop readers get the day picker
- On a phone the shared journey's map stopped at the safe area, so Safari's
  toolbar sat over a bare strip; it now runs to the edge (#3002)
- The shared journey on desktop gains the journal's day picker, every day and
  its entries one click away (#2998)
2026-11-06 20:52:07 +01:00
Maurice f46f6f38fc fix(journey): no zoom buttons on the journey map on touch screens
On a phone they sat under the bottom bar, and a finger zooms with a pinch
anyway; devices with a mouse or trackpad keep them.
2026-11-06 20:45:15 +01:00
Maurice ae082c1fd0 fix(native): the widgets show the trip the dashboard features
With several trips running at once, the widgets took whichever came first
on the device, so an old test trip spanning today pushed out the journey
under way, and the today widget counted its days. The dashboard's choice
moves to utils/heroTrip.ts (running first, earliest start, else the next to
come) and the widgets ask the same function, so the home screen always
shows the trip the app opens on.
2026-11-06 20:25:41 +01:00
Maurice 6b8eb939c3 fix(journey): the entry carousel no longer jolts when a swipe lands
The active card grew its box from 136 to 164 px once the swipe had settled,
which moved every card after it and made the snap pull the strip a second
time. Every card now keeps the same box and the active one is drawn larger
with a transform, so nothing moves; the strips keep the room with a wider gap
and top padding. Where the browser reports scrollend, the card grows the
moment the snap lands instead of a beat later.

TestFlight builds now carry the release tag's message as What to Test.
2026-11-06 20:12:26 +01:00
94 changed files with 2721 additions and 111 deletions
+13
View File
@@ -161,6 +161,19 @@ jobs:
# 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:
+2 -2
View File
@@ -150,9 +150,9 @@ jobs:
working-directory: client/ios/WidgetPreviews
run: |
mkdir -p Sources/WidgetPreviews/Widgets
cp ../App/TREKWidgets/WidgetSnapshot.swift ../App/TREKWidgets/TrekStyle.swift ../App/TREKWidgets/TrekFamily.swift \
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
swift run -c release WidgetPreviews out "$PWD/../App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png"
- uses: actions/upload-artifact@v6
with:
+5 -1
View File
@@ -103,9 +103,13 @@ platform :ios do
# 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,
skip_waiting_for_build_processing: true,
changelog: notes.empty? ? nil : notes,
skip_waiting_for_build_processing: notes.empty?,
skip_submission: true,
)
end
+22 -2
View File
@@ -26,6 +26,11 @@
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 */; };
@@ -104,6 +109,11 @@
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>"; };
@@ -254,6 +264,11 @@
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 */,
@@ -458,6 +473,11 @@
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 */,
@@ -548,7 +568,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = TREKWidgets/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.2;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -779,7 +799,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = TREKWidgets/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.2;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
+9 -2
View File
@@ -46,7 +46,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
/// and a server can require a minimum (minNativeShellApi in /api/health/features).
static let shellApi = 2
/// The native features by name, so the UI can turn each on by itself.
static let features: [String] = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity"]
static let features: [String] = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips"]
// MARK: - Shared booking documents
@@ -63,12 +63,19 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
/// 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
}
TrekSharedKeychain.write("widget.snapshot", data)
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()
}
@@ -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,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))
}
}
@@ -1,7 +1,8 @@
import SwiftUI
import WidgetKit
/// The home-screen and lock-screen widgets; their gallery names and
/// 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.
@@ -27,7 +28,10 @@ struct TrekProvider: TimelineProvider {
/// 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) {
let snapshot = WidgetSnapshot.load()
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)
@@ -38,7 +42,7 @@ struct TrekProvider: TimelineProvider {
entries.append(TrekEntry(date: calendar.date(bySetting: .second, value: 0, of: date) ?? date, snapshot: snapshot))
}
}
completion(Timeline(entries: entries, policy: .atEnd))
return Timeline(entries: entries, policy: .atEnd)
}
}
@@ -83,7 +87,7 @@ struct TrekContainer<Content: View, Background: View>: View {
struct NextTripWidget: Widget {
var body: some WidgetConfiguration {
// The kind stays "NextTrip" so widgets placed before the redesign keep working.
StaticConfiguration(kind: "NextTrip", provider: TrekProvider()) { entry in
AppIntentConfiguration(kind: "NextTrip", intent: SelectTripIntent.self, provider: TrekTripProvider()) { entry in
TripWidgetView(entry: entry)
}
.configurationDisplayName("widget.trip.name")
@@ -110,7 +114,7 @@ struct TripWidgetView: View {
struct TodayWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "TripToday", provider: TrekProvider()) { entry in
AppIntentConfiguration(kind: "TripToday", intent: SelectTripIntent.self, provider: TrekTripProvider()) { entry in
TrekContainer(link: entry.snapshot?.trip.flatMap { WidgetSnapshot.link($0.path) }) {
TodayFamilyView(entry: entry)
} background: {
@@ -133,7 +137,7 @@ private struct TodayFamilyView: View {
struct BookingsWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "TripBookings", provider: TrekProvider()) { entry in
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: {
@@ -156,7 +160,7 @@ private struct BookingsFamilyView: View {
struct TodosWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "TripTodos", provider: TrekProvider()) { entry in
AppIntentConfiguration(kind: "TripTodos", intent: SelectTripIntent.self, provider: TrekTripProvider()) { entry in
TrekContainer(link: entry.snapshot?.todos.flatMap { WidgetSnapshot.link($0.path) }) {
TodosFamilyView(entry: entry)
} background: {
@@ -174,3 +178,72 @@ private struct TodosFamilyView: View {
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)"
}
}
@@ -1,8 +1,9 @@
import SwiftUI
import WidgetKit
/// The TREK widgets: the trip, today's plan, the bookings ahead and the
/// to-dos on the home and lock screen, and the travel day as a Live Activity.
/// 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 {
@@ -10,6 +11,9 @@ struct TREKWidgetsBundle: WidgetBundle {
TodayWidget()
BookingsWidget()
TodosWidget()
PackingWidget()
AtlasWidget()
CollabWidget()
TripDayLiveActivity()
}
}
+3 -13
View File
@@ -13,7 +13,7 @@ struct TodosContent: View {
let labels = snapshot?.labels
if family.isAccessory {
accessory(todos, labels)
} else if let labels, let todos, todos.total > 0 || snapshot?.packing != nil {
} else if let labels, let todos, todos.total > 0 {
switch family {
case .small: small(todos, labels)
case .medium:
@@ -31,7 +31,6 @@ struct TodosContent: View {
rings(todos, labels, size: 96)
VStack(alignment: .leading, spacing: 8) {
header(labels, todos)
if let packing = snapshot?.packing { PackingBar(packing: packing, labels: labels) }
}
}
Divider().overlay(Trek.track(scheme)).padding(.vertical, 10)
@@ -75,18 +74,12 @@ struct TodosContent: View {
}
}
/// The to-dos as the outer ring and the packing list as the inner one.
/// 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 packing = snapshot?.packing
let packed = packing.map { $0.total > 0 ? Double($0.packed) / Double($0.total) : 1 }
let width = size * 0.085
let width = size * 0.1
return ZStack {
ProgressRing(value: done, lineWidth: width, colors: [Color(hex: 0x4A7DDB), Color(hex: 0x6A5BE0)], trackColor: Trek.track(scheme)) { EmptyView() }
if let packed {
ProgressRing(value: packed, lineWidth: width, colors: [Color(hex: 0x2FA37A), Color(hex: 0x7BC96F)], trackColor: Trek.track(scheme)) { EmptyView() }
.padding(width * 1.45)
}
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))
@@ -99,9 +92,6 @@ struct TodosContent: View {
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)")
if let packing = snapshot?.packing {
LegendPill(color: Trek.confirmed, symbol: "suitcase.rolling.fill", text: labels.packing ?? "", count: "\(packing.packed)/\(packing.total)")
}
Spacer(minLength: 0)
}
}
+52 -2
View File
@@ -33,7 +33,7 @@ extension WidgetSnapshot {
return formatter.string(from: calendar.date(byAdding: .day, value: offset, to: now) ?? now)
}
let start = running ? -2 : 12
return WidgetSnapshot(
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
@@ -76,9 +76,57 @@ extension WidgetSnapshot {
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"
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.
@@ -90,6 +138,8 @@ extension WidgetSnapshot {
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,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))
}
}
+112 -3
View File
@@ -85,6 +85,79 @@ struct WidgetSnapshot: 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 {
@@ -112,6 +185,14 @@ struct WidgetSnapshot: Codable {
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?
@@ -120,18 +201,46 @@ struct WidgetSnapshot: Codable {
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: "widget.snapshot",
kSecAttrAccount as String: account,
kSecReturnData as String: true,
]
var result: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { return nil }
return try? JSONDecoder().decode(WidgetSnapshot.self, from: data)
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.
@@ -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
}
}
@@ -7,3 +7,14 @@
"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" = "نفس رحلة لوحة التحكم";
@@ -7,3 +7,14 @@
"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";
@@ -7,3 +7,14 @@
"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";
@@ -7,3 +7,14 @@
"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";
@@ -7,3 +7,14 @@
"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";
@@ -7,3 +7,14 @@
"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" = "Το ίδιο ταξίδι με τον πίνακα";
@@ -7,3 +7,14 @@
"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";
@@ -7,3 +7,14 @@
"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";
@@ -7,3 +7,14 @@
"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";
@@ -7,3 +7,14 @@
"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";
@@ -7,3 +7,14 @@
"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" = "אותו טיול כמו בלוח הבקרה";
@@ -7,3 +7,14 @@
"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";
@@ -7,3 +7,14 @@
"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";
@@ -7,3 +7,14 @@
"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";
@@ -7,3 +7,14 @@
"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" = "ダッシュボードと同じ旅行";
@@ -7,3 +7,14 @@
"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" = "대시보드와 같은 여행";
@@ -7,3 +7,14 @@
"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";
@@ -7,3 +7,14 @@
"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";
@@ -7,3 +7,14 @@
"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";
@@ -7,3 +7,14 @@
"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" = "Та же поездка, что и на главной";
@@ -7,3 +7,14 @@
"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";
@@ -7,3 +7,14 @@
"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";
@@ -7,3 +7,14 @@
"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" = "ทริปเดียวกับแดชบอร์ด";
@@ -7,3 +7,14 @@
"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ı";
@@ -7,3 +7,14 @@
"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" = "Та сама подорож, що й на панелі";
@@ -7,3 +7,14 @@
"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";
@@ -7,3 +7,14 @@
"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" = "与仪表板相同的旅行";
@@ -7,3 +7,14 @@
"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,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)))
}
}
@@ -39,6 +39,10 @@ struct Render {
}
}
}
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)
@@ -48,6 +52,8 @@ struct Render {
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)")
}
@@ -85,7 +91,7 @@ struct Render {
}
enum WidgetKind: String, CaseIterable {
case trip, today, bookings, todos
case trip, today, bookings, todos, packing, atlas, collab
var families: [TrekFamily] {
switch self {
@@ -93,6 +99,9 @@ enum WidgetKind: String, CaseIterable {
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]
}
}
}
@@ -132,6 +141,9 @@ struct WidgetFrame: View {
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)
}
}
@@ -140,7 +152,7 @@ struct WidgetFrame: View {
if kind == .trip && snapshot.trip != nil {
TripBackdrop(trip: snapshot.trip)
} else {
TrekPaper()
TrekPaper(contours: kind != .atlas)
}
}
}
@@ -224,3 +236,37 @@ struct Ridge: Shape {
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)
@@ -84,10 +84,12 @@ describe('JourneyEntryCover', () => {
const { container, rerender } = render(
<JourneyEntryCover entry={buildEntry()} dayColor="#6366f1" isActive={false} onClick={() => {}} />,
)
expect(container.querySelector('button')!.className).toContain('w-[136px]')
expect(container.querySelector('button')!.className).toContain('scale-100')
rerender(<JourneyEntryCover entry={buildEntry()} dayColor="#6366f1" isActive onClick={() => {}} />)
expect(container.querySelector('button')!.className).toContain('w-[164px]')
// Drawn larger, not laid out larger: the box stays, so nothing beside it moves.
expect(container.querySelector('button')!.className).toContain('scale-[1.18]')
expect(container.querySelector('button')!.className).toContain('w-[136px]')
})
it('FE-JRN-COVER-007: a shared journey serves its photos through the share token', () => {
@@ -103,8 +103,13 @@ export default function JourneyEntryCover({
// The whole card is the photo, so its own ring is what separates it from the
// map behind. A suggestion is drawn as an outline rather than a solid thing:
// it is somewhere you planned to be, not somewhere you have written about.
className={`relative flex-none overflow-hidden rounded-[18px] text-start transition-[width,height] duration-150 ${
isActive ? 'h-[180px] w-[164px] shadow-[0_18px_40px_-16px_rgba(0,0,0,.55)]' : 'h-[152px] w-[136px] shadow-[0_10px_24px_-14px_rgba(0,0,0,.5)]'
//
// Every card takes the same room and the active one is only drawn larger:
// growing its box moved every card after it once a swipe had landed, and the
// snap then pulled the strip again, which read as a jolt (carousels keep the
// room for it with their gap and top padding).
className={`relative h-[152px] w-[136px] flex-none origin-bottom overflow-hidden rounded-[18px] text-start transition-[transform,box-shadow] duration-200 ease-[cubic-bezier(.2,.8,.2,1)] motion-reduce:transition-none ${
isActive ? 'z-[1] scale-[1.18] shadow-[0_18px_40px_-16px_rgba(0,0,0,.55)]' : 'scale-100 shadow-[0_10px_24px_-14px_rgba(0,0,0,.5)]'
} ${src ? '' : emptyGround} ${isSuggestion ? 'opacity-90' : ''}`}
// backgroundImage, not background: the shorthand would drop the opaque colour
// the class supplies and leave the map showing through a card that is only a
+7 -1
View File
@@ -691,7 +691,13 @@ function JourneyMap(
ref={containerRef}
style={{ width: '100%', height: '100%' }}
/>
<div style={{ position: 'absolute', bottom: 12, right: 12, zIndex: 400, display: 'flex', flexDirection: 'column', gap: 4 }}>
{/* A finger zooms with a pinch, and on a phone these sat under the bottom bar (#3001). */}
<div
// The display lives in the classes, not the style: an inline display beat the
// hidden class and left the buttons on phones.
className="flex flex-col gap-1 [@media(pointer:coarse)]:hidden"
style={{ position: 'absolute', bottom: 12, right: 12, zIndex: 400 }}
>
<button type="button" onClick={() => zoom(true)} style={zoomButtonStyle(dark)}>+</button>
<button type="button" onClick={() => zoom(false)} style={zoomButtonStyle(dark)}>−</button>
</div>
@@ -150,7 +150,7 @@ describe('MobileMapTimeline', () => {
await userEvent.click(segments[1])
const { cards } = carouselOf(container)
expect(cards[1].querySelector('button')!.className).toContain('w-[164px]')
expect(cards[1].querySelector('button')!.className).toContain('scale-[1.18]')
})
it('FE-COMP-JMAPTL-003: the first entry starts out active and drives the map highlight', () => {
@@ -69,7 +69,7 @@ export default function MobileMapTimeline({
controlsTop,
}: Props) {
const {
mapRef, carouselRef, cardRefs, activeIndex, setActiveIndex, scrubberDays,
mapRef, carouselRef, cardRefs, activeIndex, focusIndex, setActiveIndex, scrubberDays,
syncMapToCard: syncMapToCarousel, handleMarkerClick, handleCardTap, openEntryById, jumpToDay,
} = useJourneyCarouselSync({ entries, mapEntries, onOpenEntry: onEntryClick })
// The delayed initial focus reads both through refs so it always works off
@@ -108,8 +108,8 @@ export default function MobileMapTimeline({
if (entries.length === 0) {
return (
<div
className="fixed inset-x-0 z-10"
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
className="fixed inset-x-0 bottom-0 z-10"
style={{ top: 'var(--nav-h, 0px)' }}
>
<JourneyLayeredMap
ref={mapRef}
@@ -147,8 +147,10 @@ export default function MobileMapTimeline({
return (
<div
className="fixed inset-x-0 z-10"
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
// Down to the very edge: Safari draws its toolbar over the page, and a map
// that stopped at the safe area left a bare strip under it (#3002).
className="fixed inset-x-0 bottom-0 z-10"
style={{ top: 'var(--nav-h, 0px)' }}
>
{/* Full-screen map */}
<JourneyLayeredMap
@@ -187,7 +189,7 @@ export default function MobileMapTimeline({
/>
<div
ref={carouselRef}
className="flex items-end gap-[10px] overflow-x-auto px-4 pb-3 pt-1"
className="flex items-end gap-[22px] overflow-x-auto px-6 pb-3 pt-[30px]"
style={{
scrollSnapType: 'x mandatory',
WebkitOverflowScrolling: 'touch',
@@ -205,7 +207,7 @@ export default function MobileMapTimeline({
<JourneyEntryCover
entry={entry}
dayColor={dayColorOf(scrubberDays, entry.entry_date)}
isActive={i === activeIndex}
isActive={i === focusIndex}
onClick={() => handleCardTap(entry, i)}
showMood={showMood}
showWeather={showWeather}
@@ -129,7 +129,7 @@ describe('useJourneyCarouselSync', () => {
carousel.dispatchEvent(new Event('scroll'));
act(() => vi.advanceTimersByTime(100));
carousel.dispatchEvent(new Event('scroll'));
act(() => vi.advanceTimersByTime(149));
act(() => vi.advanceTimersByTime(89));
expect(hook.result.current.activeIndex).toBe(0);
act(() => vi.advanceTimersByTime(1));
// Centre 250: card 1 spans 200 to 300.
@@ -170,7 +170,7 @@ describe('useJourneyCarouselSync', () => {
Object.defineProperty(carousel, 'scrollLeft', { value: scrollLeft, configurable: true });
carousel.dispatchEvent(new Event('scroll'));
act(() => {
vi.advanceTimersByTime(150);
vi.advanceTimersByTime(90);
});
};
@@ -25,6 +25,13 @@ export interface JourneyCarouselSyncOptions<E extends CarouselEntry> {
* once scrolling settles, and the map follows it; a marker, a day on the scrubber or a
* tap brings its card to the centre.
*/
/**
* How long the strip has to be still before the swipe counts as landed: long
* enough to see past the gaps between scroll events of a momentum swipe, short
* enough that the map follows the card without a noticeable wait.
*/
const SETTLE_MS = 90;
export function useJourneyCarouselSync<E extends CarouselEntry>({
entries,
mapEntries,
@@ -35,6 +42,10 @@ export function useJourneyCarouselSync<E extends CarouselEntry>({
const carouselRef = useRef<HTMLDivElement>(null);
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map());
const [activeIndex, setActiveIndex] = useState(0);
// The card nearest the centre while a swipe is still moving: drawn larger at
// once (a transform, so nothing reflows), while the active card and the map
// only follow once the strip settles. Null when not swiping.
const [liveIndex, setLiveIndex] = useState<number | null>(null);
const scrubberDays = useMemo(() => journeyDays(entries), [entries]);
@@ -57,9 +68,9 @@ export function useJourneyCarouselSync<E extends CarouselEntry>({
// Pick the card closest to the horizontal centre. More stable than
// IntersectionObserver thresholds when the active card can drift toward the
// viewport edge with proximity snapping.
const pickNearestCard = useCallback(() => {
const nearestCard = useCallback((): number | null => {
const el = carouselRef.current;
if (!el) return;
if (!el) return null;
const center = el.getBoundingClientRect().left + el.clientWidth / 2;
let bestIdx = 0;
let bestDist = Infinity;
@@ -79,11 +90,18 @@ export function useJourneyCarouselSync<E extends CarouselEntry>({
const scrolled = Math.abs(el.scrollLeft);
if (travel > 1 && scrolled >= travel - 1) bestIdx = cardRefs.current.size - 1;
else if (travel > 1 && scrolled <= 0) bestIdx = 0;
return bestIdx;
}, []);
const pickNearestCard = useCallback(() => {
const bestIdx = nearestCard();
if (bestIdx == null) return;
setLiveIndex(null);
setActiveIndex((prev) => {
if (prev !== bestIdx) syncMapToCard(bestIdx);
return bestIdx;
});
}, [syncMapToCard]);
}, [nearestCard, syncMapToCard]);
// Defer every state update until scrolling settles: updating the active card
// mid swipe resizes the cards and reflows the layout on every frame.
@@ -91,16 +109,33 @@ export function useJourneyCarouselSync<E extends CarouselEntry>({
const el = carouselRef.current;
if (!el || entries.length === 0) return;
let settleTimer: number | null = null;
let frame: number | null = null;
const onScroll = () => {
if (frame == null) {
frame = window.requestAnimationFrame(() => {
frame = null;
setLiveIndex(nearestCard());
});
}
if (settleTimer != null) window.clearTimeout(settleTimer);
settleTimer = window.setTimeout(pickNearestCard, 150);
settleTimer = window.setTimeout(pickNearestCard, SETTLE_MS);
};
// Where the browser says when the snap has landed, the card grows right then
// instead of a beat later; the timer stays for the ones that do not.
const onScrollEnd = () => {
if (settleTimer != null) window.clearTimeout(settleTimer);
settleTimer = null;
pickNearestCard();
};
el.addEventListener('scroll', onScroll, { passive: true });
el.addEventListener('scrollend', onScrollEnd, { passive: true });
return () => {
el.removeEventListener('scroll', onScroll);
el.removeEventListener('scrollend', onScrollEnd);
if (settleTimer != null) window.clearTimeout(settleTimer);
if (frame != null) window.cancelAnimationFrame(frame);
};
}, [entries.length, pickNearestCard]);
}, [entries.length, pickNearestCard, nearestCard]);
const scrollCardIntoCenter = useCallback((idx: number) => {
cardRefs.current.get(idx)?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
@@ -159,6 +194,8 @@ export function useJourneyCarouselSync<E extends CarouselEntry>({
carouselRef,
cardRefs,
activeIndex,
/** The card to draw as the active one: the one at the centre while swiping, else the active card. */
focusIndex: liveIndex ?? activeIndex,
setActiveIndex,
scrubberDays,
syncMapToCard,
@@ -12,16 +12,20 @@ import { useTranslation } from '../../i18n';
import { useNativeCapabilities } from '../../native/capabilities';
import { bookingsAhead, packingSummary, todosSummary, tripPlace } from '../../native/widgetExtras';
import { widgetCover, widgetWeather } from '../../native/widgetMedia';
import { atlasSummary, collabSummary, packingDetail, type WidgetAtlas } from '../../native/widgetOnline';
import {
buildSnapshot,
pickTrip,
publishWidgets,
todayOf,
tripDayState,
widgetTripChoices,
widgetsActive,
withBookings,
type TripWidgetSnapshot,
type WidgetLabels,
} from '../../native/widgets';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import type { Day, Place, TranslationFn, Trip } from '../../types';
import { formatTime } from '../../utils/formatters';
@@ -53,6 +57,14 @@ function labels(t: TranslationFn): WidgetLabels {
places: t('dashboard.places'),
travellers: t('native.widget.travellers'),
allDone: t('native.widget.dayDone'),
missing: t('native.widget.missing'),
countries: t('native.widget.countries'),
world: t('native.widget.world'),
group: t('native.widget.group'),
poll: t('native.widget.poll'),
votes: t('native.widget.votes'),
noMessages: t('native.widget.noMessages'),
departure: t('native.widget.departure'),
};
}
@@ -72,13 +84,15 @@ interface SyncContext {
}
async function tripExtras(trip: Trip, days: Day[], { today, language, unit, time }: SyncContext) {
const [places, reservations, todos, packing, members] = await Promise.all([
const [places, reservations, todos, packing, memberRows] = await Promise.all([
offlineDb.places.where('trip_id').equals(trip.id).toArray(),
offlineDb.reservations.where('trip_id').equals(trip.id).toArray(),
offlineDb.todoItems.where('trip_id').equals(trip.id).toArray(),
offlineDb.packingItems.where('trip_id').equals(trip.id).toArray(),
offlineDb.tripMembers.where('tripId').equals(trip.id).count(),
offlineDb.tripMembers.where('tripId').equals(trip.id).toArray(),
]);
const members = memberRows.length;
const names = [trip.owner_username, ...memberRows.map((m) => m.username)].filter((n): n is string => !!n);
const path = `/trips/${trip.id}`;
const place = tripPlace(places);
// While the trip runs, today where it is; before, its first day, once that is in forecast range.
@@ -89,10 +103,18 @@ async function tripExtras(trip: Trip, days: Day[], { today, language, unit, time
days.find((d) => (d.date ?? '').slice(0, 10) === weatherDate),
places
);
const [weather, cover] = await Promise.all([
const [weather, cover, bags, collab] = await Promise.all([
spot && inRange ? widgetWeather(spot, { date: weatherDate, lang: language, unit, place }) : Promise.resolve(null),
widgetCover(trip.cover_image),
packingDetail(trip.id, packing),
collabSummary(
trip.id,
`/trips/${trip.id}?tab=collab`,
[...new Set(names)],
useAuthStore.getState().user?.id ?? null
),
]);
const packed = packingSummary(packing, `/trips/${trip.id}?tab=listen`);
const dayDates = new Map(days.map((d) => [d.id, (d.date ?? '').slice(0, 10)]));
return {
reservations,
@@ -105,7 +127,8 @@ async function tripExtras(trip: Trip, days: Day[], { today, language, unit, time
weather,
bookings: bookingsAhead(reservations, { dayDates, today, tripPath: path, formatTime: time }),
todos: todosSummary(todos, `${path}?tab=listen`),
packing: packingSummary(packing, `${path}?tab=listen`),
packing: packed && bags ? { ...packed, ...bags } : packed,
collab,
},
};
}
@@ -120,20 +143,43 @@ export default function NativeWidgetsSync(): null {
if (!widgetsActive()) return;
let cancelled = false;
/** One trip's snapshot with its day, as the widgets set to that trip show it. */
const tripSnapshot = async (trip: Trip | null, context: SyncContext, atlas: WidgetAtlas | null) => {
const days = trip ? await offlineDb.days.where('trip_id').equals(trip.id).toArray() : [];
const extra = trip ? await tripExtras(trip, days, context) : null;
const plain = trip
? todayOf(trip, days, context.today, (n, m) => t('native.widget.dayOf', { n, m }), context.time)
: null;
const day = plain ? withBookings(plain, extra?.reservations ?? [], context.today, context.time) : null;
return { day, snapshot: buildSnapshot(trip, day, labels(t), { ...extra?.extras, atlas }) };
};
const sync = async () => {
const now = new Date();
const today = localDateStr(now.getFullYear(), now.getMonth(), now.getDate());
const time = (value: string) => formatTime(value, language, timeFormat);
const context: SyncContext = { today, language, unit, time: (value) => formatTime(value, language, timeFormat) };
const trips = await offlineDb.trips.toArray();
const trip = pickTrip(trips, today);
const days = trip ? await offlineDb.days.where('trip_id').equals(trip.id).toArray() : [];
const extra = trip ? await tripExtras(trip, days, { today, language, unit, time }) : null;
const plain = trip ? todayOf(trip, days, today, (n, m) => t('native.widget.dayOf', { n, m }), time) : null;
const day = plain ? withBookings(plain, extra?.reservations ?? [], today, time) : null;
const hero = pickTrip(trips, today);
// The countries do not depend on a trip: the atlas widget fills without one.
const atlas = await atlasSummary(language);
const main = await tripSnapshot(hero, context, atlas);
// Every trip running or ahead also gets its own, for widgets set to it in Edit Widget.
const choices: TripWidgetSnapshot[] = [];
for (const trip of widgetTripChoices(trips, today)) {
const own = trip.id === hero?.id ? main : await tripSnapshot(trip, context, null);
choices.push({
id: trip.id,
title: trip.title,
start: trip.start_date ?? '',
end: trip.end_date ?? '',
snapshot: own.snapshot,
});
}
if (cancelled) return;
const snapshot = buildSnapshot(trip, day, labels(t), extra?.extras);
const live = day ? tripDayState(day, now.getHours() * 60 + now.getMinutes(), t('native.widget.dayDone')) : null;
await publishWidgets(snapshot, live);
const live = main.day
? tripDayState(main.day, now.getHours() * 60 + now.getMinutes(), t('native.widget.dayDone'))
: null;
await publishWidgets(main.snapshot, live, choices);
};
void sync();
@@ -102,7 +102,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
)
const {
mapRef, carouselRef, cardRefs, activeIndex, setActiveIndex, scrubberDays,
mapRef, carouselRef, cardRefs, activeIndex, focusIndex, setActiveIndex, scrubberDays,
syncMapToCard, handleMarkerClick, handleCardTap, openEntryById, jumpToDay,
} = useJourneyCarouselSync<JourneyEntry>({ entries, mapEntries: sidebarMapItems, onOpenEntry: setEditingEntry, syncMapOnTap: true })
@@ -498,7 +498,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
/>
<div
ref={carouselRef}
className="flex items-end gap-[10px] overflow-x-auto px-4 pb-1 [-webkit-overflow-scrolling:touch] [scrollbar-width:none]"
className="flex items-end gap-[22px] overflow-x-auto px-6 pb-1 pt-[30px] [-webkit-overflow-scrolling:touch] [scrollbar-width:none]"
style={{ scrollSnapType: 'x mandatory' }}
>
{entries.map((entry, i) => (
@@ -510,7 +510,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
<JourneyEntryCover
entry={entry}
dayColor={dayColorOf(scrubberDays, entry.entry_date)}
isActive={i === activeIndex}
isActive={i === focusIndex}
onClick={() => handleCardTap(entry, i)}
showMood={current.show_mood !== 0}
showWeather={current.show_weather !== 0}
+2 -1
View File
@@ -60,7 +60,8 @@ export interface TrekShellPlugin {
/** Feature nativePush: the notification the app was opened from, before the UI ran. */
takeOpenedPush(): Promise<{ url: string | null }>;
/** Feature widgets: what the home-screen widget shows (native/widgets.ts), as JSON. */
setWidgetSnapshot(options: { json: string }): Promise<void>;
/** Feature widgetTrips adds `key`: "trip.<id>" for one trip's snapshot, "trips" for the list a widget picks from. */
setWidgetSnapshot(options: { json: string; key?: string }): Promise<void>;
/** Feature liveActivity: starts the travel day's Live Activity, or updates the running one. */
syncTripDay(options: {
tripTitle: string;
+14 -6
View File
@@ -42,25 +42,33 @@ export async function widgetWeather(
return value;
}
const COVER_KEY = 'trek.widgetCover';
const COVER_KEY = 'trek.widgetCovers';
/** Covers kept, one per trip a widget can show (widgetTripChoices). */
const COVER_KEEP = 6;
const COVER_MAX_PX = 720;
const COVER_MAX_BYTES = 15 * 1024 * 1024;
/** Kept under this so the keychain item the widget reads stays small. */
const COVER_MAX_BASE64 = 160_000;
function storedCover(url: string): string | null {
function storedCovers(): { url: string; data: string }[] {
try {
const raw = localStorage.getItem(COVER_KEY);
const parsed = raw ? (JSON.parse(raw) as { url?: string; data?: string }) : null;
return parsed?.url === url && parsed.data ? parsed.data : null;
const parsed: unknown = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? (parsed as { url: string; data: string }[]) : [];
} catch {
return null;
return [];
}
}
function storedCover(url: string): string | null {
return storedCovers().find((c) => c.url === url)?.data ?? null;
}
/** Newest first, the oldest dropped past COVER_KEEP. */
function storeCover(url: string, data: string): void {
try {
localStorage.setItem(COVER_KEY, JSON.stringify({ url, data }));
const rest = storedCovers().filter((c) => c.url !== url);
localStorage.setItem(COVER_KEY, JSON.stringify([{ url, data }, ...rest].slice(0, COVER_KEEP)));
} catch {
// Storage full: the cover is fetched again next time, nothing else depends on it.
}
+227
View File
@@ -0,0 +1,227 @@
import { CONTINENT_MAP } from '@trek/shared';
import apiClient, { collabApi, packingApi } from '../api/client';
import type { PackingItem } from '../types';
/**
* The widget parts that only the server knows: the packing bags with their
* weights, the countries of the Atlas, and the trip's chat and polls. Each is
* best effort, so offline or with the addon off the widget goes without it;
* client/ios/App/TREKWidgets/WidgetSnapshot.swift reads the shapes.
*/
export interface WidgetBag {
name: string;
color: string;
grams: number;
limit?: number;
packed: number;
total: number;
}
export interface WidgetPackingDetail {
bags: WidgetBag[];
missing: { name: string; category: string | null; color: string | null }[];
}
export interface WidgetAtlas {
count: number;
codes: string[];
/** The latest visited, newest first, with their names in the user's language. */
recent: { code: string; name: string }[];
continents: { name: string; count: number }[];
trips?: number;
days?: number;
}
export interface WidgetCollab {
members: { name: string; initials: string }[];
messages: { author: string; initials: string; text: string; at: string | null; mine: boolean }[];
poll?: { question: string; options: { label: string; votes: number }[]; voters: number };
path: string;
}
const HEX = /^#[0-9a-f]{6}$/i;
/**
* Server answers kept for a while: the widgets sync every quarter hour for up
* to six trips, and bags, chat and polls do not change that fast.
*/
const NETWORK_TTL_MS = 10 * 60 * 1000;
const networkCache = new Map<string, { at: number; value: unknown }>();
async function cached<T>(key: string, read: () => Promise<T>): Promise<T> {
const hit = networkCache.get(key);
if (hit && Date.now() - hit.at < NETWORK_TTL_MS) return hit.value as T;
const value = await read();
networkCache.set(key, { at: Date.now(), value });
return value;
}
function initials(name: string): string {
const parts = name
.trim()
.split(/[\s._-]+/)
.filter(Boolean);
const letters = parts.length > 1 ? parts[0][0] + parts[1][0] : name.trim().slice(0, 2);
return letters.toUpperCase();
}
/** The bags with what is in them, and the items still to pack, those in a bag first. */
export async function packingDetail(
tripId: number,
items: PackingItem[],
limit = 8
): Promise<WidgetPackingDetail | null> {
if (items.length === 0) return null;
let bags: {
id: number;
name: string;
color: string;
weight_limit_grams?: number | null;
total_weight_grams?: number;
}[] = [];
try {
const res = await cached(`bags:${tripId}`, () => packingApi.listBags(tripId) as Promise<{ bags?: typeof bags }>);
bags = res.bags ?? [];
} catch {
// Offline or no bags: the widget shows the items without them.
bags = [];
}
const byBag = new Map(bags.map((b) => [b.id, b]));
const summaries = bags.map((b) => {
const inBag = items.filter((i) => i.bag_id === b.id);
const grams = b.total_weight_grams ?? inBag.reduce((sum, i) => sum + (i.weight_grams ?? 0) * (i.quantity ?? 1), 0);
return {
name: b.name,
// Empty when the bag has no usable colour: the widget then uses its own blue.
color: HEX.test(b.color) ? b.color : '',
grams,
...(b.weight_limit_grams ? { limit: b.weight_limit_grams } : {}),
packed: inBag.filter((i) => i.checked).length,
total: inBag.length,
};
});
const missing = items
.filter((i) => !i.checked)
.sort((a, b) => Number(b.bag_id != null) - Number(a.bag_id != null) || a.sort_order - b.sort_order)
.slice(0, limit)
.map((i) => {
const bag = i.bag_id != null ? byBag.get(i.bag_id) : undefined;
return { name: i.name, category: i.category ?? null, color: bag && HEX.test(bag.color) ? bag.color : null };
});
return { bags: summaries.filter((b) => b.total > 0), missing };
}
interface AtlasStats {
countries?: { code: string; lastVisit?: string | null; firstVisit?: string | null }[];
stats?: { totalCountries?: number; totalTrips?: number; totalDays?: number };
}
const ATLAS_TTL_MS = 60 * 60 * 1000;
let atlasCache: { at: number; language: string; value: WidgetAtlas | null } | null = null;
/** The countries of the Atlas, the latest ones named, and how they spread over the continents. */
export async function atlasSummary(language: string): Promise<WidgetAtlas | null> {
if (atlasCache && atlasCache.language === language && Date.now() - atlasCache.at < ATLAS_TTL_MS)
return atlasCache.value;
const value = await readAtlas(language);
atlasCache = { at: Date.now(), language, value };
return value;
}
async function readAtlas(language: string): Promise<WidgetAtlas | null> {
let data: AtlasStats;
try {
data = (await apiClient.get('/addons/atlas/stats', { timeout: 10_000 })).data as AtlasStats;
} catch {
// The addon is off or the server unreachable: no atlas widget data this time.
return null;
}
const countries = (data.countries ?? []).filter((c) => /^[A-Z]{2}$/.test(c.code));
let names: Intl.DisplayNames | null = null;
try {
names = new Intl.DisplayNames([language], { type: 'region' });
} catch {
names = null;
}
const recent = [...countries]
.sort((a, b) => (b.lastVisit ?? b.firstVisit ?? '').localeCompare(a.lastVisit ?? a.firstVisit ?? ''))
.slice(0, 4)
.map((c) => ({ code: c.code, name: names?.of(c.code) ?? c.code }));
const perContinent = new Map<string, number>();
for (const c of countries) {
const continent = CONTINENT_MAP[c.code];
if (continent) perContinent.set(continent, (perContinent.get(continent) ?? 0) + 1);
}
return {
count: data.stats?.totalCountries ?? countries.length,
codes: countries.map((c) => c.code),
recent,
continents: [...perContinent].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count),
...(data.stats?.totalTrips ? { trips: data.stats.totalTrips } : {}),
...(data.stats?.totalDays ? { days: data.stats.totalDays } : {}),
};
}
interface Message {
user_id: number;
username: string;
text: string;
created_at: string | null;
deleted: number | null;
}
interface Poll {
question: string;
is_closed: boolean;
options: { text: unknown; label: unknown; voters: { user_id: number }[] }[];
}
/** Who travels along, the latest messages in the trip chat, and the open poll. */
export async function collabSummary(
tripId: number,
path: string,
members: string[],
me: number | null
): Promise<WidgetCollab | null> {
let messages: Message[] = [];
let polls: Poll[] = [];
try {
const [m, p] = await Promise.all([
cached(`messages:${tripId}`, () => collabApi.getMessages(tripId) as Promise<{ messages?: Message[] }>),
cached(`polls:${tripId}`, () => collabApi.getPolls(tripId) as Promise<{ polls?: Poll[] }>),
]);
messages = m.messages ?? [];
polls = p.polls ?? [];
} catch {
// Offline or the collab addon is off: the widget shows the members only.
if (members.length < 2) return null;
}
const latest = messages
.filter((m) => !m.deleted && m.text.trim())
.sort((a, b) => (a.created_at ?? '').localeCompare(b.created_at ?? ''))
.slice(-3)
.map((m) => ({
author: m.username,
initials: initials(m.username),
text: m.text.trim().slice(0, 140),
at: m.created_at,
mine: me != null && m.user_id === me,
}));
const open = polls.find((p) => !p.is_closed);
const label = (o: Poll['options'][number]) => String(o.label ?? o.text ?? '');
const poll = open
? {
question: open.question,
options: open.options.slice(0, 4).map((o) => ({ label: label(o), votes: o.voters.length })),
voters: new Set(open.options.flatMap((o) => o.voters.map((v) => v.user_id))).size,
}
: undefined;
if (members.length < 2 && latest.length === 0 && !poll) return null;
return {
members: members.slice(0, 6).map((name) => ({ name, initials: initials(name) })),
messages: latest,
...(poll ? { poll } : {}),
path,
};
}
+43 -1
View File
@@ -13,7 +13,15 @@ vi.mock('./trekShell', () => ({
}));
import { TrekShell } from './trekShell';
import { buildSnapshot, pickTrip, publishWidgets, todayOf, tripDayState, withBookings } from './widgets';
import {
buildSnapshot,
pickTrip,
publishWidgets,
todayOf,
tripDayState,
widgetTripChoices,
withBookings,
} from './widgets';
const trip = (id: number, start: string | null, end: string | null, archived = 0) =>
({ id, title: `Trip ${id}`, start_date: start, end_date: end, is_archived: archived }) as unknown as Trip;
@@ -51,6 +59,15 @@ describe('pickTrip', () => {
expect(pickTrip(trips, '2026-11-12')?.id).toBe(2);
});
it('of several running trips takes the dashboard hero: the one that started first', () => {
const trips = [
trip(1, '2026-11-02', '2026-11-06'),
trip(2, '2026-10-20', '2026-11-18'),
trip(3, '2026-11-04', '2026-11-12'),
];
expect(pickTrip(trips, '2026-11-06')?.id).toBe(2);
});
it('skips undated, archived and finished trips', () => {
expect(
pickTrip(
@@ -211,3 +228,28 @@ describe('the richer snapshot', () => {
expect(snapshot).not.toHaveProperty('packing');
});
});
describe('widgets set to a trip', () => {
it('offers the trips running or ahead, in the dashboard order, at most six', () => {
const trips = [
trip(1, '2026-01-01', '2026-01-05'),
trip(2, '2026-12-01', '2026-12-05'),
trip(3, '2026-11-04', '2026-11-12'),
trip(4, '2026-11-20', '2026-11-22', 1),
trip(5, null, null),
...[6, 7, 8, 9, 10, 11].map((id) => trip(id, `2027-0${id - 5}-01`, `2027-0${id - 5}-03`)),
];
expect(widgetTripChoices(trips, '2026-11-06').map((t) => t.id)).toEqual([3, 2, 6, 7, 8, 9]);
});
it('writes each trip under its own key and the list to pick from', async () => {
vi.mocked(TrekShell.setWidgetSnapshot).mockClear();
const snapshot = buildSnapshot(trip(2, '2026-12-01', '2026-12-05'), null, labels);
await publishWidgets(snapshot, null, [
{ id: 2, title: 'Trip 2', start: '2026-12-01', end: '2026-12-05', snapshot },
]);
const calls = vi.mocked(TrekShell.setWidgetSnapshot).mock.calls.map(([options]) => options);
expect(calls.map((c) => c.key)).toEqual([undefined, 'trip.2', 'trips']);
expect(JSON.parse(calls[2].json)).toEqual([{ id: 2, title: 'Trip 2', start: '2026-12-01', end: '2026-12-05' }]);
});
});
+54 -12
View File
@@ -1,10 +1,12 @@
import type { Day, Reservation, Trip } from '../types';
import { splitReservationDateTime } from '../utils/formatters';
import { heroTrip, sortTripsByRelevance } from '../utils/heroTrip';
import { hasNativeFeature } from './capabilities';
import { isNativeIos } from './platform';
import { reportNativeFailure } from './reportFailure';
import { TrekShell } from './trekShell';
import { stopLook, type WidgetBooking, type WidgetPacking, type WidgetTodos } from './widgetExtras';
import type { WidgetAtlas, WidgetCollab, WidgetPackingDetail } from './widgetOnline';
/**
* What the home-screen widgets and the travel-day Live Activity show, built from
@@ -37,6 +39,14 @@ export interface WidgetLabels {
places?: string;
travellers?: string;
allDone?: string;
missing?: string;
countries?: string;
world?: string;
group?: string;
poll?: string;
votes?: string;
noMessages?: string;
departure?: string;
}
interface WidgetStop {
@@ -73,7 +83,9 @@ export interface WidgetSnapshot {
weather?: WidgetWeather;
bookings?: WidgetBooking[];
todos?: WidgetTodos;
packing?: WidgetPacking;
packing?: WidgetPacking & Partial<WidgetPackingDetail>;
atlas?: WidgetAtlas;
collab?: WidgetCollab;
labels: WidgetLabels;
}
@@ -98,7 +110,9 @@ interface SnapshotExtras {
weather?: WidgetWeather | null;
bookings?: WidgetBooking[];
todos?: WidgetTodos;
packing?: WidgetPacking | null;
packing?: (WidgetPacking & Partial<WidgetPackingDetail>) | null;
atlas?: WidgetAtlas | null;
collab?: WidgetCollab | null;
}
export interface TripDay {
@@ -121,17 +135,23 @@ function minutesOf(time: string | null | undefined): number | null {
}
/**
* The running trip if there is one, else the next to start. Trips without dates
* and trips already over are not candidates.
* The trip the dashboard's hero features (utils/heroTrip.ts): the running one,
* else the next to start, so the widgets show the trip the app opens on.
* Archived trips are not candidates, as on the dashboard.
*/
export function pickTrip(trips: Trip[], today: string): Trip | null {
const dated = trips.flatMap((t) =>
t.start_date && t.end_date && !t.is_archived ? [{ trip: t, start: t.start_date, end: t.end_date }] : []
);
const running = dated.find((d) => d.start <= today && d.end >= today);
if (running) return running.trip;
const upcoming = dated.filter((d) => d.start > today).sort((a, b) => a.start.localeCompare(b.start));
return upcoming[0]?.trip ?? null;
const active = trips.filter((t) => !t.is_archived);
return heroTrip(sortTripsByRelevance(active, today), today);
}
/**
* The trips a widget can be set to in Edit Widget: the ones running or coming
* up, in the dashboard's order (so the hero's trip comes first), at most `limit`,
* each of which gets a snapshot of its own.
*/
export function widgetTripChoices(trips: Trip[], today: string, limit = 6): Trip[] {
const ahead = trips.filter((t) => !t.is_archived && t.start_date && (t.end_date ?? t.start_date) >= today);
return sortTripsByRelevance(ahead, today).slice(0, limit);
}
/** The symbols of the booking kinds a day's timeline shows next to its stops. */
@@ -262,6 +282,8 @@ export function buildSnapshot(
if (extras.bookings) snapshot.bookings = extras.bookings;
if (extras.todos) snapshot.todos = extras.todos;
if (extras.packing) snapshot.packing = extras.packing;
if (extras.atlas) snapshot.atlas = extras.atlas;
if (extras.collab) snapshot.collab = extras.collab;
return snapshot;
}
@@ -282,12 +304,32 @@ export function tripDayState(day: TripDay, nowMinutes: number, allDone: string)
};
}
/** A trip a widget can be set to in Edit Widget, and its snapshot. */
export interface TripWidgetSnapshot {
id: number;
title: string;
start: string;
end: string;
snapshot: WidgetSnapshot;
}
export async function publishWidgets(
snapshot: WidgetSnapshot,
liveDay: ReturnType<typeof tripDayState> | null
liveDay: ReturnType<typeof tripDayState> | null,
trips: TripWidgetSnapshot[] = []
): Promise<void> {
if (!widgetsActive()) return;
await TrekShell.setWidgetSnapshot({ json: JSON.stringify(snapshot) }).catch(reportNativeFailure);
// An app binary without widgetTrips would take every one of these for the dashboard's snapshot.
if (hasNativeFeature('widgetTrips')) {
for (const trip of trips) {
await TrekShell.setWidgetSnapshot({ json: JSON.stringify(trip.snapshot), key: `trip.${trip.id}` }).catch(
reportNativeFailure
);
}
const index = trips.map(({ id, title, start, end }) => ({ id, title, start, end }));
await TrekShell.setWidgetSnapshot({ json: JSON.stringify(index), key: 'trips' }).catch(reportNativeFailure);
}
if (!hasNativeFeature('liveActivity')) return;
if (liveDay && liveDay.total > 0) await TrekShell.syncTripDay(liveDay).catch(reportNativeFailure);
else await TrekShell.endTripDay().catch(reportNativeFailure);
+19 -2
View File
@@ -20,6 +20,7 @@ import {
ThumbsUp,
} from 'lucide-react';
import { DAY_COLORS } from '../components/Journey/dayColors';
import JourneyDayJump from '../components/Journey/JourneyDayJump';
import JournalBody from '../components/Journey/JournalBody';
import JourneyLayeredMap from '../components/Journey/JourneyLayeredMap';
import MobileEntryView from '../components/Journey/MobileEntryView';
@@ -120,6 +121,7 @@ export default function JourneyPublicPage() {
showLangPicker,
setShowLangPicker,
mapRef,
feedRef,
activeEntryId,
setActiveEntryId,
viewingEntry,
@@ -227,7 +229,7 @@ export default function JourneyPublicPage() {
const dayIdx = sortedDates.indexOf(date);
const dayColor = DAY_COLORS[dayIdx % DAY_COLORS.length];
return (
<div key={date}>
<div key={date} data-day={date}>
{/* Day header */}
<div className="mb-4 flex items-center gap-3">
<div
@@ -624,9 +626,24 @@ export default function JourneyPublicPage() {
// ── Desktop two-column: scrollable timeline feed + sticky map ──────────
<div className="mx-auto flex max-w-[1440px]" style={{ alignItems: 'flex-start' }}>
{/* Left: feed */}
<div className="min-w-0 flex-1 px-8 py-6 xl:max-w-[50%]">
<div ref={feedRef} className="min-w-0 flex-1 px-8 py-6 xl:max-w-[50%]">
{view === 'timeline' && perms.share_timeline && renderTimeline()}
{view === 'gallery' && perms.share_gallery && renderGallery()}
{/* Every day and its entries one click away, as in the journal itself (#2998). */}
{view === 'timeline' && perms.share_timeline && sortedDates.length > 1 && (
<div className="pointer-events-none sticky bottom-0 z-20 h-0">
<div className="pointer-events-auto absolute inset-x-0 bottom-6 mx-auto w-fit">
<JourneyDayJump
feedRef={feedRef}
days={sortedDates.map((date, i) => ({
date,
color: DAY_COLORS[i % DAY_COLORS.length],
entries: (groupedEntries.get(date) ?? []).map(e => ({ id: e.id, title: e.title || e.location_name || t('journey.detail.newEntry') })),
}))}
/>
</div>
</div>
)}
</div>
{/* Right: sticky map — matches auth page aside proportions */}
+2 -13
View File
@@ -7,6 +7,7 @@
import type { Trip } from '../../types'
import { localIsoDate } from '../../utils/localDate'
import { sortTripsByRelevance } from '../../utils/heroTrip'
// The dashboard works with the canonical Trip shape returned by the list/get
// endpoints (it already carries the computed day_count/place_count/is_owner/
@@ -83,17 +84,5 @@ export function getTripStatus(trip: DashboardTrip): 'ongoing' | 'today' | 'tomor
}
export function sortTrips(trips: DashboardTrip[]): DashboardTrip[] {
const today = localIsoToday()
const rank = (t: DashboardTrip) => {
if (t.start_date && t.end_date && t.start_date <= today && t.end_date >= today) return 0
if (t.start_date && t.start_date >= today) return 1
return 2
}
return [...trips].sort((a, b) => {
const ra = rank(a), rb = rank(b)
if (ra !== rb) return ra - rb
const ad = a.start_date || '', bd = b.start_date || ''
if (ra <= 1) return ad.localeCompare(bd)
return bd.localeCompare(ad)
})
return sortTripsByRelevance(trips, localIsoToday())
}
+2 -3
View File
@@ -9,6 +9,7 @@ import { useTranslation } from '../../i18n'
import { useToast } from '../../components/shared/Toast'
import { getApiErrorMessage } from '../../types'
import { localIsoToday } from './dashboardModel'
import { heroTrip } from '../../utils/heroTrip'
import { useTripSearch } from './useTripSearch'
import type { TripCreateRequest } from '@trek/shared'
import {
@@ -120,9 +121,7 @@ export function useDashboard() {
// A trip the hero features on its own merit: one that is running, else the next
// one coming up. Only that one is taken out of the grid below, so the same trip
// isn't shown twice.
const featured = trips.find(t => t.start_date && t.end_date && t.start_date <= today && t.end_date >= today)
|| trips.find(t => t.start_date && t.start_date >= today)
|| null
const featured = heroTrip(trips, today)
// With neither, the hero still shows something rather than sitting empty — but
// that trip is only borrowed for the header and must stay in the grid, or a user
// whose trips are all finished (or undated) sees "No trips yet". #1706
@@ -32,6 +32,8 @@ export function useJourneyPublic() {
const [showLangPicker, setShowLangPicker] = useState(false)
const locale = useSettingsStore(s => s.settings.language) || 'en'
const mapRef = useRef<JourneyMapHandle>(null)
// The desktop feed, for the day picker to scroll (#2998).
const feedRef = useRef<HTMLDivElement>(null)
const [activeEntryId, setActiveEntryId] = useState<string | null>(null)
const [viewingEntry, setViewingEntry] = useState<PublicEntry | null>(null)
@@ -170,7 +172,7 @@ export function useJourneyPublic() {
return {
token, data, loading, error, isMobile, locale,
view, setView, lightbox, setLightbox, showLangPicker, setShowLangPicker,
mapRef, activeEntryId, setActiveEntryId, viewingEntry, setViewingEntry, handleMarkerClick,
mapRef, feedRef, activeEntryId, setActiveEntryId, viewingEntry, setViewingEntry, handleMarkerClick,
perms, journey, stats,
timelineEntries, groupedEntries, sortedDates, displayDates, sidebarMapItems, allPhotos, stopNumberById, mapPhotos,
mapLayers, entryTitleOf,
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { heroTrip, sortTripsByRelevance } from './heroTrip';
const trip = (id: number, start_date: string | null, end_date: string | null) => ({ id, start_date, end_date });
describe('heroTrip', () => {
const today = '2026-11-06';
const trips = [
trip(1, '2026-01-01', '2026-01-05'),
trip(2, '2026-12-01', '2026-12-05'),
trip(3, '2026-11-04', '2026-11-12'),
trip(4, '2026-10-20', '2026-11-18'),
trip(5, null, null),
];
it('orders running trips first, then the ones coming up, then the rest, latest first', () => {
expect(sortTripsByRelevance(trips, today).map((t) => t.id)).toEqual([4, 3, 2, 1, 5]);
});
it('features the running trip that started first, else the next to come', () => {
expect(heroTrip(sortTripsByRelevance(trips, today), today)?.id).toBe(4);
expect(heroTrip(sortTripsByRelevance([trips[0], trips[1]], today), today)?.id).toBe(2);
expect(heroTrip(sortTripsByRelevance([trips[0], trips[4]], today), today)).toBeNull();
});
});
+37
View File
@@ -0,0 +1,37 @@
/**
* Which trip the dashboard features, in one place: the dashboard's hero and
* the iOS widgets (native/widgets.ts) both ask here, so the trip on the home
* screen is always the one the app opens on.
*/
interface DatedTrip {
start_date?: string | null;
end_date?: string | null;
}
/** 0 running, 1 coming up, 2 past or undated. */
function rank(trip: DatedTrip, today: string): number {
if (trip.start_date && trip.end_date && trip.start_date <= today && trip.end_date >= today) return 0;
if (trip.start_date && trip.start_date >= today) return 1;
return 2;
}
/** Running trips first, then the ones coming up, both soonest first; the rest latest first. */
export function sortTripsByRelevance<T extends DatedTrip>(trips: T[], today: string): T[] {
return [...trips].sort((a, b) => {
const ra = rank(a, today);
const rb = rank(b, today);
if (ra !== rb) return ra - rb;
const ad = a.start_date || '';
const bd = b.start_date || '';
if (ra <= 1) return ad.localeCompare(bd);
return bd.localeCompare(ad);
});
}
/**
* The trip the hero features on its own merit: the first running one in that
* order, else the first coming up; null when every trip is past or undated.
*/
export function heroTrip<T extends DatedTrip>(sorted: T[], today: string): T | null {
return sorted.find((t) => rank(t, today) === 0) || sorted.find((t) => rank(t, today) === 1) || null;
}
+8
View File
@@ -93,6 +93,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'لم يتبق شيء',
'native.widget.now': 'الآن',
'native.widget.travellers': 'مسافرون',
'native.widget.missing': 'متبقٍ للتجهيز',
'native.widget.countries': 'دول',
'native.widget.world': 'من العالم',
'native.widget.group': 'مجموعة السفر',
'native.widget.poll': 'تصويت',
'native.widget.votes': 'أصوات',
'native.widget.noMessages': 'لا توجد رسائل بعد',
'native.widget.departure': 'المغادرة',
};
export default native;
+8
View File
@@ -95,6 +95,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'Görüləsi iş qalmayıb',
'native.widget.now': 'İndi',
'native.widget.travellers': 'səyyah',
'native.widget.missing': 'hələ yığılacaq',
'native.widget.countries': 'ölkə',
'native.widget.world': 'dünyanın',
'native.widget.group': 'Səyahət qrupu',
'native.widget.poll': 'Səsvermə',
'native.widget.votes': 'səs',
'native.widget.noMessages': 'Hələ mesaj yoxdur',
'native.widget.departure': 'Yola düşmə',
};
export default native;
+8
View File
@@ -95,6 +95,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'Nada pendente',
'native.widget.now': 'Agora',
'native.widget.travellers': 'viajantes',
'native.widget.missing': 'ainda falta arrumar',
'native.widget.countries': 'países',
'native.widget.world': 'do mundo',
'native.widget.group': 'Grupo de viagem',
'native.widget.poll': 'Votação',
'native.widget.votes': 'votos',
'native.widget.noMessages': 'Nenhuma mensagem ainda',
'native.widget.departure': 'Partida',
};
export default native;
+8
View File
@@ -95,6 +95,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'No queda res per fer',
'native.widget.now': 'Ara',
'native.widget.travellers': 'viatgers',
'native.widget.missing': 'per fer la maleta',
'native.widget.countries': 'països',
'native.widget.world': 'del món',
'native.widget.group': 'Grup de viatge',
'native.widget.poll': 'Votació',
'native.widget.votes': 'vots',
'native.widget.noMessages': 'Encara no hi ha missatges',
'native.widget.departure': 'Sortida',
};
export default native;
+8
View File
@@ -95,6 +95,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'Vše hotovo',
'native.widget.now': 'Teď',
'native.widget.travellers': 'cestovatelů',
'native.widget.missing': 'zbývá zabalit',
'native.widget.countries': 'zemí',
'native.widget.world': 'světa',
'native.widget.group': 'Cestovní skupina',
'native.widget.poll': 'Hlasování',
'native.widget.votes': 'hlasů',
'native.widget.noMessages': 'Zatím žádné zprávy',
'native.widget.departure': 'Odjezd',
};
export default native;
+8
View File
@@ -96,6 +96,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'Alles erledigt',
'native.widget.now': 'Jetzt',
'native.widget.travellers': 'Reisende',
'native.widget.missing': 'noch zu packen',
'native.widget.countries': 'Länder',
'native.widget.world': 'der Welt',
'native.widget.group': 'Reisegruppe',
'native.widget.poll': 'Abstimmung',
'native.widget.votes': 'Stimmen',
'native.widget.noMessages': 'Noch keine Nachrichten',
'native.widget.departure': 'Abreise',
};
export default native;
+8
View File
@@ -95,6 +95,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'Nothing left to do',
'native.widget.now': 'Now',
'native.widget.travellers': 'travellers',
'native.widget.missing': 'still to pack',
'native.widget.countries': 'countries',
'native.widget.world': 'of the world',
'native.widget.group': 'Travel group',
'native.widget.poll': 'Poll',
'native.widget.votes': 'votes',
'native.widget.noMessages': 'No messages yet',
'native.widget.departure': 'Departure',
};
export default native;
+8
View File
@@ -96,6 +96,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'No queda nada pendiente',
'native.widget.now': 'Ahora',
'native.widget.travellers': 'viajeros',
'native.widget.missing': 'por empacar',
'native.widget.countries': 'países',
'native.widget.world': 'del mundo',
'native.widget.group': 'Grupo de viaje',
'native.widget.poll': 'Votación',
'native.widget.votes': 'votos',
'native.widget.noMessages': 'Aún no hay mensajes',
'native.widget.departure': 'Salida',
};
export default native;
+8
View File
@@ -95,6 +95,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'Kõik on tehtud',
'native.widget.now': 'Praegu',
'native.widget.travellers': 'reisijat',
'native.widget.missing': 'veel pakkida',
'native.widget.countries': 'riiki',
'native.widget.world': 'maailmast',
'native.widget.group': 'Reisiseltskond',
'native.widget.poll': 'Hääletus',
'native.widget.votes': 'häält',
'native.widget.noMessages': 'Sõnumeid veel pole',
'native.widget.departure': 'Väljasõit',
};
export default native;
+8
View File
@@ -96,6 +96,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'Plus rien à faire',
'native.widget.now': 'Maintenant',
'native.widget.travellers': 'voyageurs',
'native.widget.missing': 'encore à emballer',
'native.widget.countries': 'pays',
'native.widget.world': 'du monde',
'native.widget.group': 'Groupe de voyage',
'native.widget.poll': 'Sondage',
'native.widget.votes': 'voix',
'native.widget.noMessages': 'Pas encore de messages',
'native.widget.departure': 'Départ',
};
export default native;
+8
View File
@@ -98,6 +98,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'Δεν απομένει τίποτα',
'native.widget.now': 'Τώρα',
'native.widget.travellers': 'ταξιδιώτες',
'native.widget.missing': 'για πακετάρισμα',
'native.widget.countries': 'χώρες',
'native.widget.world': 'του κόσμου',
'native.widget.group': 'Ομάδα ταξιδιού',
'native.widget.poll': 'Ψηφοφορία',
'native.widget.votes': 'ψήφοι',
'native.widget.noMessages': 'Δεν υπάρχουν ακόμα μηνύματα',
'native.widget.departure': 'Αναχώρηση',
};
export default native;
+8
View File
@@ -93,6 +93,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'לא נשאר מה לעשות',
'native.widget.now': 'עכשיו',
'native.widget.travellers': 'מטיילים',
'native.widget.missing': 'עוד לארוז',
'native.widget.countries': 'מדינות',
'native.widget.world': 'מהעולם',
'native.widget.group': 'קבוצת הטיול',
'native.widget.poll': 'סקר',
'native.widget.votes': 'קולות',
'native.widget.noMessages': 'אין עדיין הודעות',
'native.widget.departure': 'יציאה',
};
export default native;
+8
View File
@@ -97,6 +97,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'Minden kész',
'native.widget.now': 'Most',
'native.widget.travellers': 'utazó',
'native.widget.missing': 'még bepakolni',
'native.widget.countries': 'ország',
'native.widget.world': 'a világból',
'native.widget.group': 'Útitársak',
'native.widget.poll': 'Szavazás',
'native.widget.votes': 'szavazat',
'native.widget.noMessages': 'Még nincs üzenet',
'native.widget.departure': 'Indulás',
};
export default native;
+8
View File
@@ -96,6 +96,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'Semua beres',
'native.widget.now': 'Sekarang',
'native.widget.travellers': 'pelancong',
'native.widget.missing': 'belum dikemas',
'native.widget.countries': 'negara',
'native.widget.world': 'dari dunia',
'native.widget.group': 'Rombongan',
'native.widget.poll': 'Polling',
'native.widget.votes': 'suara',
'native.widget.noMessages': 'Belum ada pesan',
'native.widget.departure': 'Keberangkatan',
};
export default native;
+8
View File
@@ -96,6 +96,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'Niente da fare',
'native.widget.now': 'Adesso',
'native.widget.travellers': 'viaggiatori',
'native.widget.missing': 'ancora da preparare',
'native.widget.countries': 'paesi',
'native.widget.world': 'del mondo',
'native.widget.group': 'Gruppo di viaggio',
'native.widget.poll': 'Sondaggio',
'native.widget.votes': 'voti',
'native.widget.noMessages': 'Ancora nessun messaggio',
'native.widget.departure': 'Partenza',
};
export default native;
+8
View File
@@ -96,6 +96,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'やることはありません',
'native.widget.now': '現在',
'native.widget.travellers': '旅行者',
'native.widget.missing': '未パッキング',
'native.widget.countries': 'か国',
'native.widget.world': '世界の',
'native.widget.group': '旅行グループ',
'native.widget.poll': '投票',
'native.widget.votes': '票',
'native.widget.noMessages': 'まだメッセージはありません',
'native.widget.departure': '出発',
};
export default native;
+8
View File
@@ -94,6 +94,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': '남은 할 일이 없습니다',
'native.widget.now': '지금',
'native.widget.travellers': '여행자',
'native.widget.missing': '챙길 짐',
'native.widget.countries': '개국',
'native.widget.world': '전 세계의',
'native.widget.group': '여행 그룹',
'native.widget.poll': '투표',
'native.widget.votes': '표',
'native.widget.noMessages': '아직 메시지가 없습니다',
'native.widget.departure': '출발',
};
export default native;
+8
View File
@@ -95,6 +95,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'Niets meer te doen',
'native.widget.now': 'Nu',
'native.widget.travellers': 'reizigers',
'native.widget.missing': 'nog in te pakken',
'native.widget.countries': 'landen',
'native.widget.world': 'van de wereld',
'native.widget.group': 'Reisgezelschap',
'native.widget.poll': 'Peiling',
'native.widget.votes': 'stemmen',
'native.widget.noMessages': 'Nog geen berichten',
'native.widget.departure': 'Vertrek',
};
export default native;
+8
View File
@@ -95,6 +95,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'Wszystko zrobione',
'native.widget.now': 'Teraz',
'native.widget.travellers': 'podróżnych',
'native.widget.missing': 'do spakowania',
'native.widget.countries': 'krajów',
'native.widget.world': 'świata',
'native.widget.group': 'Grupa podróży',
'native.widget.poll': 'Ankieta',
'native.widget.votes': 'głosów',
'native.widget.noMessages': 'Brak wiadomości',
'native.widget.departure': 'Wyjazd',
};
export default native;
+8
View File
@@ -96,6 +96,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'Всё сделано',
'native.widget.now': 'Сейчас',
'native.widget.travellers': 'путешественников',
'native.widget.missing': 'ещё собрать',
'native.widget.countries': 'стран',
'native.widget.world': 'мира',
'native.widget.group': 'Группа поездки',
'native.widget.poll': 'Опрос',
'native.widget.votes': 'голосов',
'native.widget.noMessages': 'Сообщений пока нет',
'native.widget.departure': 'Отъезд',
};
export default native;
+8
View File
@@ -95,6 +95,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'Všetko hotové',
'native.widget.now': 'Teraz',
'native.widget.travellers': 'cestovateľov',
'native.widget.missing': 'zostáva zbaliť',
'native.widget.countries': 'krajín',
'native.widget.world': 'sveta',
'native.widget.group': 'Cestovná skupina',
'native.widget.poll': 'Hlasovanie',
'native.widget.votes': 'hlasov',
'native.widget.noMessages': 'Zatiaľ žiadne správy',
'native.widget.departure': 'Odchod',
};
export default native;
+8
View File
@@ -96,6 +96,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'Inget kvar att göra',
'native.widget.now': 'Nu',
'native.widget.travellers': 'resenärer',
'native.widget.missing': 'kvar att packa',
'native.widget.countries': 'länder',
'native.widget.world': 'av världen',
'native.widget.group': 'Resesällskap',
'native.widget.poll': 'Omröstning',
'native.widget.votes': 'röster',
'native.widget.noMessages': 'Inga meddelanden än',
'native.widget.departure': 'Avresa',
};
export default native;
+8
View File
@@ -93,6 +93,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'ไม่มีอะไรค้างแล้ว',
'native.widget.now': 'ตอนนี้',
'native.widget.travellers': 'ผู้เดินทาง',
'native.widget.missing': 'ยังต้องจัด',
'native.widget.countries': 'ประเทศ',
'native.widget.world': 'ของโลก',
'native.widget.group': 'กลุ่มเดินทาง',
'native.widget.poll': 'โหวต',
'native.widget.votes': 'เสียง',
'native.widget.noMessages': 'ยังไม่มีข้อความ',
'native.widget.departure': 'ออกเดินทาง',
};
export default native;
+8
View File
@@ -96,6 +96,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'Yapılacak bir şey kalmadı',
'native.widget.now': 'Şimdi',
'native.widget.travellers': 'gezgin',
'native.widget.missing': 'hâlâ toplanacak',
'native.widget.countries': 'ülke',
'native.widget.world': 'dünyanın',
'native.widget.group': 'Seyahat grubu',
'native.widget.poll': 'Anket',
'native.widget.votes': 'oy',
'native.widget.noMessages': 'Henüz mesaj yok',
'native.widget.departure': 'Hareket',
};
export default native;
+8
View File
@@ -95,6 +95,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'Усе зроблено',
'native.widget.now': 'Зараз',
'native.widget.travellers': 'мандрівників',
'native.widget.missing': 'ще спакувати',
'native.widget.countries': 'країн',
'native.widget.world': 'світу',
'native.widget.group': 'Група подорожі',
'native.widget.poll': 'Опитування',
'native.widget.votes': 'голосів',
'native.widget.noMessages': 'Повідомлень поки немає',
'native.widget.departure': 'Відʼїзд',
};
export default native;
+8
View File
@@ -96,6 +96,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': 'Không còn việc gì',
'native.widget.now': 'Bây giờ',
'native.widget.travellers': 'du khách',
'native.widget.missing': 'còn phải soạn',
'native.widget.countries': 'quốc gia',
'native.widget.world': 'thế giới',
'native.widget.group': 'Nhóm du lịch',
'native.widget.poll': 'Bình chọn',
'native.widget.votes': 'phiếu',
'native.widget.noMessages': 'Chưa có tin nhắn',
'native.widget.departure': 'Khởi hành',
};
export default native;
+8
View File
@@ -93,6 +93,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': '沒有待辦事項',
'native.widget.now': '現在',
'native.widget.travellers': '旅客',
'native.widget.missing': '待打包',
'native.widget.countries': '個國家',
'native.widget.world': '全世界的',
'native.widget.group': '旅行小組',
'native.widget.poll': '投票',
'native.widget.votes': '票',
'native.widget.noMessages': '還沒有訊息',
'native.widget.departure': '出發',
};
export default native;
+8
View File
@@ -93,6 +93,14 @@ const native: TranslationStrings = {
'native.widget.noTodos': '没有待办事项',
'native.widget.now': '现在',
'native.widget.travellers': '旅行者',
'native.widget.missing': '待打包',
'native.widget.countries': '个国家',
'native.widget.world': '全世界的',
'native.widget.group': '旅行小组',
'native.widget.poll': '投票',
'native.widget.votes': '票',
'native.widget.noMessages': '还没有消息',
'native.widget.departure': '出发',
};
export default native;