mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe7847fc08 | |||
| 77b9252577 | |||
| 3364c0d131 | |||
| deabaf7398 | |||
| 9b780d6b7e | |||
| 93727084ec | |||
| ed178f3b5e | |||
| f46f6f38fc | |||
| ae082c1fd0 | |||
| 6b8eb939c3 |
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user