mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 08:11:03 +00:00
feat(native): home and lock screen widgets in the TREK look
Four widgets instead of one plain text block, drawn in the phone tokens (warm paper by day, near black by night) with contour lines and a route arc as the travel motif: - Trip (small, medium, large, lock screen): the cover photo, a countdown ring and a plane on its arc before the trip; the day of the trip as segments, the weather and the next stops on frosted glass while it runs - Today (medium, large): the day as a timeline in its category colours, with a now line, the day's progress and the weather - Bookings (small, medium, large, lock screen): flights, trains, hotels and tables ahead as coloured tiles, the next one featured, status dots - To-dos (small, medium, large, lock screen): to-dos and packing as nested rings, the most pressing open to-dos with priority and due dates The app now sends the destination, a small cover JPEG, the weather where the trip is, the bookings ahead, the open to-dos and the packing count; every new field is optional on both sides, so older binaries and servers keep working. Timelines step every quarter hour so the now line moves without the app. client/ios/WidgetPreviews renders every widget in every size, light and dark, on the CI's Mac, uploaded as the widget-previews artifact.
This commit is contained in:
+175
-11
@@ -1,14 +1,19 @@
|
||||
import type { Day, Trip } from '../types';
|
||||
import type { Day, Reservation, Trip } from '../types';
|
||||
import { splitReservationDateTime } from '../utils/formatters';
|
||||
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';
|
||||
|
||||
/**
|
||||
* What the home-screen widget and the travel-day Live Activity show, built from
|
||||
* What the home-screen widgets and the travel-day Live Activity show, built from
|
||||
* the trips this device keeps offline (db/offlineDb) and handed to the app
|
||||
* (TrekShell.setWidgetSnapshot, syncTripDay). The texts go along translated, so
|
||||
* the widget speaks the app's language without localising anything itself.
|
||||
* the widgets speak the app's language without localising anything themselves.
|
||||
* Everything past trip, today and the first six labels is optional on the Swift
|
||||
* side (client/ios/App/TREKWidgets/WidgetSnapshot.swift), so an older app
|
||||
* binary ignores it and a newer one copes without it.
|
||||
*/
|
||||
export interface WidgetLabels {
|
||||
nextTrip: string;
|
||||
@@ -18,26 +23,92 @@ export interface WidgetLabels {
|
||||
inDays: string;
|
||||
tomorrow: string;
|
||||
nothingPlanned: string;
|
||||
days?: string;
|
||||
bookings?: string;
|
||||
noBookings?: string;
|
||||
todos?: string;
|
||||
noTodos?: string;
|
||||
packing?: string;
|
||||
confirmed?: string;
|
||||
pending?: string;
|
||||
todayShort?: string;
|
||||
tomorrowShort?: string;
|
||||
now?: string;
|
||||
places?: string;
|
||||
travellers?: string;
|
||||
allDone?: string;
|
||||
}
|
||||
|
||||
interface WidgetStop {
|
||||
time: string | null;
|
||||
name: string;
|
||||
/** Minutes after midnight, for picking the next stop; not sent to the widget. */
|
||||
/** Minutes after midnight, for picking the next stop and the widget's now line. */
|
||||
minutes: number | null;
|
||||
color?: string | null;
|
||||
symbol?: string | null;
|
||||
}
|
||||
|
||||
interface SnapshotStop {
|
||||
time: string | null;
|
||||
name: string;
|
||||
minutes?: number;
|
||||
color?: string;
|
||||
symbol?: string;
|
||||
}
|
||||
|
||||
export interface WidgetSnapshot {
|
||||
trip: { title: string; start: string; end: string; path: string } | null;
|
||||
today: { dayLabel: string; stops: { time: string | null; name: string }[] } | null;
|
||||
trip: {
|
||||
title: string;
|
||||
start: string;
|
||||
end: string;
|
||||
path: string;
|
||||
place?: string;
|
||||
/** A small JPEG of the cover, base64. */
|
||||
cover?: string;
|
||||
dayCount?: number;
|
||||
placeCount?: number;
|
||||
memberCount?: number;
|
||||
} | null;
|
||||
today: { dayLabel: string; stops: SnapshotStop[]; index?: number; total?: number } | null;
|
||||
weather?: WidgetWeather;
|
||||
bookings?: WidgetBooking[];
|
||||
todos?: WidgetTodos;
|
||||
packing?: WidgetPacking;
|
||||
labels: WidgetLabels;
|
||||
}
|
||||
|
||||
export interface WidgetWeather {
|
||||
temp: number;
|
||||
high?: number;
|
||||
low?: number;
|
||||
/** The provider's main condition ("Clear", "Rain", ...), which the widget turns into a symbol. */
|
||||
main: string;
|
||||
label: string;
|
||||
place?: string;
|
||||
/** Chance of rain in percent. */
|
||||
rain?: number;
|
||||
}
|
||||
|
||||
/** What goes along with the trip beyond its day; each part optional. */
|
||||
interface SnapshotExtras {
|
||||
place?: string | null;
|
||||
cover?: string | null;
|
||||
placeCount?: number;
|
||||
memberCount?: number;
|
||||
weather?: WidgetWeather | null;
|
||||
bookings?: WidgetBooking[];
|
||||
todos?: WidgetTodos;
|
||||
packing?: WidgetPacking | null;
|
||||
}
|
||||
|
||||
export interface TripDay {
|
||||
tripTitle: string;
|
||||
path: string;
|
||||
dayLabel: string;
|
||||
stops: WidgetStop[];
|
||||
/** 1-based position of the day in the trip, and the trip's day count. */
|
||||
index?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
export function widgetsActive(): boolean {
|
||||
@@ -63,6 +134,31 @@ export function pickTrip(trips: Trip[], today: string): Trip | null {
|
||||
return upcoming[0]?.trip ?? null;
|
||||
}
|
||||
|
||||
/** The symbols of the booking kinds a day's timeline shows next to its stops. */
|
||||
const RESERVATION_SYMBOLS: Record<string, string> = {
|
||||
flight: 'airplane',
|
||||
train: 'tram.fill',
|
||||
bus: 'bus.fill',
|
||||
car: 'car.fill',
|
||||
cruise: 'ferry.fill',
|
||||
ferry: 'ferry.fill',
|
||||
hotel: 'bed.double.fill',
|
||||
restaurant: 'fork.knife',
|
||||
event: 'ticket.fill',
|
||||
tour: 'figure.hiking',
|
||||
};
|
||||
|
||||
/** Puts each timed booking before the first stop that comes later; untimed stops keep their place. */
|
||||
function mergeByTime(stops: WidgetStop[], booked: WidgetStop[]): WidgetStop[] {
|
||||
const out = [...stops];
|
||||
for (const b of [...booked].sort((x, y) => (x.minutes ?? 0) - (y.minutes ?? 0))) {
|
||||
const at = out.findIndex((s) => s.minutes != null && s.minutes > (b.minutes ?? 0));
|
||||
if (at < 0) out.push(b);
|
||||
else out.splice(at, 0, b);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Today's day of the trip with its stops in plan order, if the trip runs today. */
|
||||
export function todayOf(
|
||||
trip: Trip,
|
||||
@@ -77,28 +173,96 @@ export function todayOf(
|
||||
const day = ordered[index];
|
||||
const stops = [...(day.assignments ?? [])]
|
||||
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
||||
.flatMap((a) =>
|
||||
.flatMap((a): WidgetStop[] =>
|
||||
a.place?.name
|
||||
? [
|
||||
{
|
||||
name: a.place.name,
|
||||
time: a.place.place_time ? formatTime(a.place.place_time.slice(0, 5)) : null,
|
||||
minutes: minutesOf(a.place.place_time),
|
||||
...stopLook(a.place.category),
|
||||
},
|
||||
]
|
||||
: []
|
||||
);
|
||||
return { tripTitle: trip.title, path: `/trips/${trip.id}`, dayLabel: dayLabel(index + 1, ordered.length), stops };
|
||||
return {
|
||||
tripTitle: trip.title,
|
||||
path: `/trips/${trip.id}`,
|
||||
dayLabel: dayLabel(index + 1, ordered.length),
|
||||
stops,
|
||||
index: index + 1,
|
||||
total: ordered.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSnapshot(trip: Trip | null, day: TripDay | null, labels: WidgetLabels): WidgetSnapshot {
|
||||
/** The day with its timed bookings (a flight, the table tonight) joined to the stops in time order. */
|
||||
export function withBookings(
|
||||
day: TripDay,
|
||||
reservations: Reservation[],
|
||||
today: string,
|
||||
formatTime: (time: string) => string
|
||||
): TripDay {
|
||||
const booked = reservations.flatMap((r): WidgetStop[] => {
|
||||
const { date, time } = splitReservationDateTime(r.reservation_time);
|
||||
if (r.status === 'cancelled' || !time || date !== today) return [];
|
||||
return [
|
||||
{
|
||||
name: r.title,
|
||||
time: formatTime(time),
|
||||
minutes: minutesOf(time),
|
||||
color: null,
|
||||
symbol: RESERVATION_SYMBOLS[r.type] ?? 'bookmark.fill',
|
||||
},
|
||||
];
|
||||
});
|
||||
return booked.length ? { ...day, stops: mergeByTime(day.stops, booked) } : day;
|
||||
}
|
||||
|
||||
/** A stop as the widget gets it: the empty fields left out rather than sent as null. */
|
||||
function snapshotStop({ time, name, minutes, color, symbol }: WidgetStop): SnapshotStop {
|
||||
return {
|
||||
time,
|
||||
name,
|
||||
...(minutes != null ? { minutes } : {}),
|
||||
...(color ? { color } : {}),
|
||||
...(symbol ? { symbol } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSnapshot(
|
||||
trip: Trip | null,
|
||||
day: TripDay | null,
|
||||
labels: WidgetLabels,
|
||||
extras: SnapshotExtras = {}
|
||||
): WidgetSnapshot {
|
||||
const snapshot: WidgetSnapshot = {
|
||||
trip: trip
|
||||
? { title: trip.title, start: trip.start_date ?? '', end: trip.end_date ?? '', path: `/trips/${trip.id}` }
|
||||
? {
|
||||
title: trip.title,
|
||||
start: trip.start_date ?? '',
|
||||
end: trip.end_date ?? '',
|
||||
path: `/trips/${trip.id}`,
|
||||
...(extras.place ? { place: extras.place } : {}),
|
||||
...(extras.cover ? { cover: extras.cover } : {}),
|
||||
...(trip.day_count ? { dayCount: trip.day_count } : {}),
|
||||
...(extras.placeCount ? { placeCount: extras.placeCount } : {}),
|
||||
...(extras.memberCount ? { memberCount: extras.memberCount } : {}),
|
||||
}
|
||||
: null,
|
||||
today: day
|
||||
? {
|
||||
dayLabel: day.dayLabel,
|
||||
stops: day.stops.map(snapshotStop),
|
||||
...(day.index ? { index: day.index, total: day.total } : {}),
|
||||
}
|
||||
: null,
|
||||
today: day ? { dayLabel: day.dayLabel, stops: day.stops.map(({ time, name }) => ({ time, name })) } : null,
|
||||
labels,
|
||||
};
|
||||
if (extras.weather) snapshot.weather = extras.weather;
|
||||
if (extras.bookings) snapshot.bookings = extras.bookings;
|
||||
if (extras.todos) snapshot.todos = extras.todos;
|
||||
if (extras.packing) snapshot.packing = extras.packing;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** The Live Activity's state: the first stop whose time is still ahead, else the last. */
|
||||
|
||||
Reference in New Issue
Block a user