From 93727084ec3cb6a992f1790bf16abc5e9124242c Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 6 Nov 2026 20:44:09 +0100 Subject: [PATCH] feat(native): packing, world map and travel group widgets - Packing: a ring of what is in the bags, each bag against its weight limit, the items still missing in their bag's colour, a nudge when departure is two days or less away - World map: the world as dots from the Atlas country shapes (WorldGrid.swift, generated by WidgetPreviews/make-world-grid.py), the visited countries glowing, the latest one ringed, continents as bars and the latest countries with their flags; it needs no trip - Travel group: who comes along as avatars, the latest chat messages as bubbles and the open poll with its votes as bars - The to-dos widget is about to-dos only; the packing ring moved out The app sends the bags, the Atlas countries (an hour's cache) and the chat and poll with the snapshot; all optional, as before. --- .github/workflows/native.yml | 2 +- client/ios/App/App.xcodeproj/project.pbxproj | 16 ++ client/ios/App/TREKWidgets/AtlasContent.swift | 234 ++++++++++++++++++ .../ios/App/TREKWidgets/CollabContent.swift | 207 ++++++++++++++++ .../ios/App/TREKWidgets/NextTripWidget.swift | 69 ++++++ .../ios/App/TREKWidgets/PackingContent.swift | 209 ++++++++++++++++ .../App/TREKWidgets/TREKWidgetsBundle.swift | 8 +- client/ios/App/TREKWidgets/TodosContent.swift | 16 +- client/ios/App/TREKWidgets/TrekFamily.swift | 54 +++- .../ios/App/TREKWidgets/WidgetSnapshot.swift | 83 +++++++ client/ios/App/TREKWidgets/WorldGrid.swift | 53 ++++ .../TREKWidgets/ar.lproj/Localizable.strings | 6 + .../TREKWidgets/az.lproj/Localizable.strings | 6 + .../TREKWidgets/ca.lproj/Localizable.strings | 6 + .../TREKWidgets/cs.lproj/Localizable.strings | 6 + .../TREKWidgets/de.lproj/Localizable.strings | 6 + .../TREKWidgets/el.lproj/Localizable.strings | 6 + .../TREKWidgets/en.lproj/Localizable.strings | 6 + .../TREKWidgets/es.lproj/Localizable.strings | 6 + .../TREKWidgets/et.lproj/Localizable.strings | 6 + .../TREKWidgets/fr.lproj/Localizable.strings | 6 + .../TREKWidgets/he.lproj/Localizable.strings | 6 + .../TREKWidgets/hu.lproj/Localizable.strings | 6 + .../TREKWidgets/id.lproj/Localizable.strings | 6 + .../TREKWidgets/it.lproj/Localizable.strings | 6 + .../TREKWidgets/ja.lproj/Localizable.strings | 6 + .../TREKWidgets/ko.lproj/Localizable.strings | 6 + .../TREKWidgets/nl.lproj/Localizable.strings | 6 + .../TREKWidgets/pl.lproj/Localizable.strings | 6 + .../pt-BR.lproj/Localizable.strings | 6 + .../TREKWidgets/ru.lproj/Localizable.strings | 6 + .../TREKWidgets/sk.lproj/Localizable.strings | 6 + .../TREKWidgets/sv.lproj/Localizable.strings | 6 + .../TREKWidgets/th.lproj/Localizable.strings | 6 + .../TREKWidgets/tr.lproj/Localizable.strings | 6 + .../TREKWidgets/uk.lproj/Localizable.strings | 6 + .../TREKWidgets/vi.lproj/Localizable.strings | 6 + .../zh-Hans.lproj/Localizable.strings | 6 + .../zh-Hant.lproj/Localizable.strings | 6 + .../Sources/WidgetPreviews/Render.swift | 48 +++- client/ios/WidgetPreviews/make-world-grid.py | 93 +++++++ .../components/Layout/NativeWidgetsSync.tsx | 37 ++- client/src/native/widgetOnline.ts | 211 ++++++++++++++++ client/src/native/widgets.ts | 19 +- shared/src/i18n/ar/native.ts | 8 + shared/src/i18n/az/native.ts | 8 + shared/src/i18n/br/native.ts | 8 + shared/src/i18n/ca/native.ts | 8 + shared/src/i18n/cs/native.ts | 8 + shared/src/i18n/de/native.ts | 8 + shared/src/i18n/en/native.ts | 8 + shared/src/i18n/es/native.ts | 8 + shared/src/i18n/et/native.ts | 8 + shared/src/i18n/fr/native.ts | 8 + shared/src/i18n/gr/native.ts | 8 + shared/src/i18n/he/native.ts | 8 + shared/src/i18n/hu/native.ts | 8 + shared/src/i18n/id/native.ts | 8 + shared/src/i18n/it/native.ts | 8 + shared/src/i18n/ja/native.ts | 8 + shared/src/i18n/ko/native.ts | 8 + shared/src/i18n/nl/native.ts | 8 + shared/src/i18n/pl/native.ts | 8 + shared/src/i18n/ru/native.ts | 8 + shared/src/i18n/sk/native.ts | 8 + shared/src/i18n/sv/native.ts | 8 + shared/src/i18n/th/native.ts | 8 + shared/src/i18n/tr/native.ts | 8 + shared/src/i18n/uk/native.ts | 8 + shared/src/i18n/vi/native.ts | 8 + shared/src/i18n/zh-TW/native.ts | 8 + shared/src/i18n/zh/native.ts | 8 + 72 files changed, 1723 insertions(+), 28 deletions(-) create mode 100644 client/ios/App/TREKWidgets/AtlasContent.swift create mode 100644 client/ios/App/TREKWidgets/CollabContent.swift create mode 100644 client/ios/App/TREKWidgets/PackingContent.swift create mode 100644 client/ios/App/TREKWidgets/WorldGrid.swift create mode 100644 client/ios/WidgetPreviews/make-world-grid.py create mode 100644 client/src/native/widgetOnline.ts diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml index 041d4bd0b..6b04eaf55 100644 --- a/.github/workflows/native.yml +++ b/.github/workflows/native.yml @@ -150,7 +150,7 @@ 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 diff --git a/client/ios/App/App.xcodeproj/project.pbxproj b/client/ios/App/App.xcodeproj/project.pbxproj index 4df12abc5..b6745bdaf 100644 --- a/client/ios/App/App.xcodeproj/project.pbxproj +++ b/client/ios/App/App.xcodeproj/project.pbxproj @@ -26,6 +26,10 @@ 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 */; }; + 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 +108,10 @@ BE41D7212DCB0C78E00A8462 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; C7064A9DA92FCC8ACAA4F22A /* NextTripWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NextTripWidget.swift; sourceTree = ""; }; DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetSnapshot.swift; sourceTree = ""; }; + 4D004544572B0E85DE29A7B8 /* AtlasContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AtlasContent.swift; sourceTree = ""; }; + 04C0771C8582246A26E0C98E /* PackingContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PackingContent.swift; sourceTree = ""; }; + 7D5041DDDAFF5B491B053999 /* CollabContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CollabContent.swift; sourceTree = ""; }; + D0AC751335E0136865E08990 /* WorldGrid.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WorldGrid.swift; sourceTree = ""; }; 15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekStyle.swift; sourceTree = ""; }; A030A06726542A2D7F61C54B /* TrekFamily.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekFamily.swift; sourceTree = ""; }; DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripContent.swift; sourceTree = ""; }; @@ -254,6 +262,10 @@ 337775CD9091673D0ADDF325 /* TripDayAttributes.swift */, 3259F6C40216CECEBAC6F307 /* TripDayLiveActivity.swift */, DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */, + 4D004544572B0E85DE29A7B8 /* AtlasContent.swift */, + 04C0771C8582246A26E0C98E /* PackingContent.swift */, + 7D5041DDDAFF5B491B053999 /* CollabContent.swift */, + D0AC751335E0136865E08990 /* WorldGrid.swift */, 15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */, A030A06726542A2D7F61C54B /* TrekFamily.swift */, DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */, @@ -458,6 +470,10 @@ 492FA24FBB57059B0B5007CE /* TripDayAttributes.swift in Sources */, DF633789F2F667C4CF264C9E /* TripDayLiveActivity.swift in Sources */, 7FB730DC35C234816EAE65AF /* WidgetSnapshot.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 */, diff --git a/client/ios/App/TREKWidgets/AtlasContent.swift b/client/ios/App/TREKWidgets/AtlasContent.swift new file mode 100644 index 000000000..494152b71 --- /dev/null +++ b/client/ios/App/TREKWidgets/AtlasContent.swift @@ -0,0 +1,234 @@ +import SwiftUI + +/// 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 + 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.. 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). + private func blend(_ t: CGFloat) -> Color { + guard accent.count > 1 else { return accent.first ?? .white } + return t < 0.5 ? accent[0] : accent[accent.count - 1] + } +} + +/// 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))) + } +} diff --git a/client/ios/App/TREKWidgets/CollabContent.swift b/client/ios/App/TREKWidgets/CollabContent.swift new file mode 100644 index 000000000..7f66fd19c --- /dev/null +++ b/client/ios/App/TREKWidgets/CollabContent.swift @@ -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.3) { + 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.38, weight: .heavy)).foregroundStyle(.white)) + .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)) + } +} diff --git a/client/ios/App/TREKWidgets/NextTripWidget.swift b/client/ios/App/TREKWidgets/NextTripWidget.swift index 5c5b3de2c..84249c54c 100644 --- a/client/ios/App/TREKWidgets/NextTripWidget.swift +++ b/client/ios/App/TREKWidgets/NextTripWidget.swift @@ -174,3 +174,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 { + StaticConfiguration(kind: "TripPacking", provider: TrekProvider()) { 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 { + StaticConfiguration(kind: "TripCollab", provider: TrekProvider()) { 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)) } +} diff --git a/client/ios/App/TREKWidgets/PackingContent.swift b/client/ios/App/TREKWidgets/PackingContent.swift new file mode 100644 index 000000000..805e003fa --- /dev/null +++ b/client/ios/App/TREKWidgets/PackingContent.swift @@ -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)" + } +} diff --git a/client/ios/App/TREKWidgets/TREKWidgetsBundle.swift b/client/ios/App/TREKWidgets/TREKWidgetsBundle.swift index 18bc9a271..fd673f6ae 100644 --- a/client/ios/App/TREKWidgets/TREKWidgetsBundle.swift +++ b/client/ios/App/TREKWidgets/TREKWidgetsBundle.swift @@ -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() } } diff --git a/client/ios/App/TREKWidgets/TodosContent.swift b/client/ios/App/TREKWidgets/TodosContent.swift index dba69fd37..22e54d391 100644 --- a/client/ios/App/TREKWidgets/TodosContent.swift +++ b/client/ios/App/TREKWidgets/TodosContent.swift @@ -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) } } diff --git a/client/ios/App/TREKWidgets/TrekFamily.swift b/client/ios/App/TREKWidgets/TrekFamily.swift index 6b3f3cc25..6bfc90d33 100644 --- a/client/ios/App/TREKWidgets/TrekFamily.swift +++ b/client/ios/App/TREKWidgets/TrekFamily.swift @@ -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 } } diff --git a/client/ios/App/TREKWidgets/WidgetSnapshot.swift b/client/ios/App/TREKWidgets/WidgetSnapshot.swift index 45a4d90c2..fdb690aa2 100644 --- a/client/ios/App/TREKWidgets/WidgetSnapshot.swift +++ b/client/ios/App/TREKWidgets/WidgetSnapshot.swift @@ -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,6 +201,8 @@ struct WidgetSnapshot: Codable { var bookings: [Booking]? var todos: Todos? var packing: Packing? + var atlas: Atlas? + var collab: Collab? var labels: Labels static func load() -> WidgetSnapshot? { diff --git a/client/ios/App/TREKWidgets/WorldGrid.swift b/client/ios/App/TREKWidgets/WorldGrid.swift new file mode 100644 index 000000000..9d4dc7102 --- /dev/null +++ b/client/ios/App/TREKWidgets/WorldGrid.swift @@ -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.. 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.. 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 +101,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 +125,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, }, }; } @@ -127,11 +148,15 @@ export default function NativeWidgetsSync(): null { 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 [extra, atlas] = await Promise.all([ + trip ? tripExtras(trip, days, { today, language, unit, time }) : Promise.resolve(null), + // The countries do not depend on a trip: the atlas widget fills without one. + atlasSummary(language), + ]); 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; if (cancelled) return; - const snapshot = buildSnapshot(trip, day, labels(t), extra?.extras); + const snapshot = buildSnapshot(trip, day, labels(t), { ...extra?.extras, atlas }); const live = day ? tripDayState(day, now.getHours() * 60 + now.getMinutes(), t('native.widget.dayDone')) : null; await publishWidgets(snapshot, live); }; diff --git a/client/src/native/widgetOnline.ts b/client/src/native/widgetOnline.ts new file mode 100644 index 000000000..375cc62cf --- /dev/null +++ b/client/src/native/widgetOnline.ts @@ -0,0 +1,211 @@ +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; + +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 { + 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 packingApi.listBags(tripId)) as { 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, + color: HEX.test(b.color) ? b.color : '#4A7DDB', + 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 { + 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 { + 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(); + 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 { + let messages: Message[] = []; + let polls: Poll[] = []; + try { + const [m, p] = await Promise.all([ + collabApi.getMessages(tripId) as Promise<{ messages?: Message[] }>, + 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, + }; +} diff --git a/client/src/native/widgets.ts b/client/src/native/widgets.ts index cf109afac..33210ac30 100644 --- a/client/src/native/widgets.ts +++ b/client/src/native/widgets.ts @@ -6,6 +6,7 @@ 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 @@ -38,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 { @@ -74,7 +83,9 @@ export interface WidgetSnapshot { weather?: WidgetWeather; bookings?: WidgetBooking[]; todos?: WidgetTodos; - packing?: WidgetPacking; + packing?: WidgetPacking & Partial; + atlas?: WidgetAtlas; + collab?: WidgetCollab; labels: WidgetLabels; } @@ -99,7 +110,9 @@ interface SnapshotExtras { weather?: WidgetWeather | null; bookings?: WidgetBooking[]; todos?: WidgetTodos; - packing?: WidgetPacking | null; + packing?: (WidgetPacking & Partial) | null; + atlas?: WidgetAtlas | null; + collab?: WidgetCollab | null; } export interface TripDay { @@ -259,6 +272,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; } diff --git a/shared/src/i18n/ar/native.ts b/shared/src/i18n/ar/native.ts index 9a9bcbef5..4add90ef6 100644 --- a/shared/src/i18n/ar/native.ts +++ b/shared/src/i18n/ar/native.ts @@ -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; diff --git a/shared/src/i18n/az/native.ts b/shared/src/i18n/az/native.ts index 14aed3523..130e48b3d 100644 --- a/shared/src/i18n/az/native.ts +++ b/shared/src/i18n/az/native.ts @@ -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; diff --git a/shared/src/i18n/br/native.ts b/shared/src/i18n/br/native.ts index be792d42e..506543143 100644 --- a/shared/src/i18n/br/native.ts +++ b/shared/src/i18n/br/native.ts @@ -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; diff --git a/shared/src/i18n/ca/native.ts b/shared/src/i18n/ca/native.ts index 0eb8f33ce..702e42094 100644 --- a/shared/src/i18n/ca/native.ts +++ b/shared/src/i18n/ca/native.ts @@ -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; diff --git a/shared/src/i18n/cs/native.ts b/shared/src/i18n/cs/native.ts index b14f97830..502b4149f 100644 --- a/shared/src/i18n/cs/native.ts +++ b/shared/src/i18n/cs/native.ts @@ -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; diff --git a/shared/src/i18n/de/native.ts b/shared/src/i18n/de/native.ts index 845b9f736..fc0c20e3d 100644 --- a/shared/src/i18n/de/native.ts +++ b/shared/src/i18n/de/native.ts @@ -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; diff --git a/shared/src/i18n/en/native.ts b/shared/src/i18n/en/native.ts index 0f322baa3..2e4bd3e2b 100644 --- a/shared/src/i18n/en/native.ts +++ b/shared/src/i18n/en/native.ts @@ -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; diff --git a/shared/src/i18n/es/native.ts b/shared/src/i18n/es/native.ts index a014d51f8..93cfe30c3 100644 --- a/shared/src/i18n/es/native.ts +++ b/shared/src/i18n/es/native.ts @@ -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; diff --git a/shared/src/i18n/et/native.ts b/shared/src/i18n/et/native.ts index f840644a5..f55eecdb5 100644 --- a/shared/src/i18n/et/native.ts +++ b/shared/src/i18n/et/native.ts @@ -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; diff --git a/shared/src/i18n/fr/native.ts b/shared/src/i18n/fr/native.ts index 355ad8f10..a87a19fa8 100644 --- a/shared/src/i18n/fr/native.ts +++ b/shared/src/i18n/fr/native.ts @@ -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; diff --git a/shared/src/i18n/gr/native.ts b/shared/src/i18n/gr/native.ts index 79b2cc350..f0a0ed4fd 100644 --- a/shared/src/i18n/gr/native.ts +++ b/shared/src/i18n/gr/native.ts @@ -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; diff --git a/shared/src/i18n/he/native.ts b/shared/src/i18n/he/native.ts index 1f1767074..08d9ea761 100644 --- a/shared/src/i18n/he/native.ts +++ b/shared/src/i18n/he/native.ts @@ -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; diff --git a/shared/src/i18n/hu/native.ts b/shared/src/i18n/hu/native.ts index 2f080b442..9d6ba2c60 100644 --- a/shared/src/i18n/hu/native.ts +++ b/shared/src/i18n/hu/native.ts @@ -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; diff --git a/shared/src/i18n/id/native.ts b/shared/src/i18n/id/native.ts index 3a981cbb4..451fb869a 100644 --- a/shared/src/i18n/id/native.ts +++ b/shared/src/i18n/id/native.ts @@ -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; diff --git a/shared/src/i18n/it/native.ts b/shared/src/i18n/it/native.ts index 55604762f..c1e03e734 100644 --- a/shared/src/i18n/it/native.ts +++ b/shared/src/i18n/it/native.ts @@ -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; diff --git a/shared/src/i18n/ja/native.ts b/shared/src/i18n/ja/native.ts index 8e175afbd..3816c75c5 100644 --- a/shared/src/i18n/ja/native.ts +++ b/shared/src/i18n/ja/native.ts @@ -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; diff --git a/shared/src/i18n/ko/native.ts b/shared/src/i18n/ko/native.ts index 89f40acf5..c936e8ea8 100644 --- a/shared/src/i18n/ko/native.ts +++ b/shared/src/i18n/ko/native.ts @@ -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; diff --git a/shared/src/i18n/nl/native.ts b/shared/src/i18n/nl/native.ts index 88fe84a6b..c433bff66 100644 --- a/shared/src/i18n/nl/native.ts +++ b/shared/src/i18n/nl/native.ts @@ -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; diff --git a/shared/src/i18n/pl/native.ts b/shared/src/i18n/pl/native.ts index 32e28638c..9109d8a26 100644 --- a/shared/src/i18n/pl/native.ts +++ b/shared/src/i18n/pl/native.ts @@ -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; diff --git a/shared/src/i18n/ru/native.ts b/shared/src/i18n/ru/native.ts index 54edac2ef..2f286ef86 100644 --- a/shared/src/i18n/ru/native.ts +++ b/shared/src/i18n/ru/native.ts @@ -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; diff --git a/shared/src/i18n/sk/native.ts b/shared/src/i18n/sk/native.ts index 914eb531b..fb88d9e33 100644 --- a/shared/src/i18n/sk/native.ts +++ b/shared/src/i18n/sk/native.ts @@ -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; diff --git a/shared/src/i18n/sv/native.ts b/shared/src/i18n/sv/native.ts index 9f342c095..cd8cea9fd 100644 --- a/shared/src/i18n/sv/native.ts +++ b/shared/src/i18n/sv/native.ts @@ -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; diff --git a/shared/src/i18n/th/native.ts b/shared/src/i18n/th/native.ts index 5b61dbf9b..5f8be69bf 100644 --- a/shared/src/i18n/th/native.ts +++ b/shared/src/i18n/th/native.ts @@ -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; diff --git a/shared/src/i18n/tr/native.ts b/shared/src/i18n/tr/native.ts index c846f0118..761d62395 100644 --- a/shared/src/i18n/tr/native.ts +++ b/shared/src/i18n/tr/native.ts @@ -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; diff --git a/shared/src/i18n/uk/native.ts b/shared/src/i18n/uk/native.ts index 6a34ee6ac..b9e61936c 100644 --- a/shared/src/i18n/uk/native.ts +++ b/shared/src/i18n/uk/native.ts @@ -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; diff --git a/shared/src/i18n/vi/native.ts b/shared/src/i18n/vi/native.ts index e2ce2ea60..13e89e0c2 100644 --- a/shared/src/i18n/vi/native.ts +++ b/shared/src/i18n/vi/native.ts @@ -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; diff --git a/shared/src/i18n/zh-TW/native.ts b/shared/src/i18n/zh-TW/native.ts index 7481ebfe4..9f2cb0a1f 100644 --- a/shared/src/i18n/zh-TW/native.ts +++ b/shared/src/i18n/zh-TW/native.ts @@ -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; diff --git a/shared/src/i18n/zh/native.ts b/shared/src/i18n/zh/native.ts index c4bfb4ce6..22fe852e1 100644 --- a/shared/src/i18n/zh/native.ts +++ b/shared/src/i18n/zh/native.ts @@ -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;