Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions src/lib/components/ErrorMessage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,32 @@
import { errorMessages, keyboard } from '$lib/ui.svelte';
import { flip } from 'svelte/animate';
import { fly } from 'svelte/transition';
import IconAlertTriangle from '@tabler/icons-svelte/icons/alert-triangle';

interface Props {
/** Window-top offset of the station menu sheet, so toasts sit right above it instead of covering the bike list. */
menuPos?: number;
}
let { menuPos = undefined }: Props = $props();

let innerHeight = $state(0);
const bottom = $derived(menuPos !== undefined && menuPos < innerHeight ? innerHeight - menuPos + 12 : 40);
</script>

<div class="flex flex-col pointer-events-none z-[110] absolute bottom-10 left-1/2 -translate-x-1/2 items-center gap-2" style:padding-bottom={Math.max(0, keyboard.height - 20) + 'px'}>
<svelte:window bind:innerHeight />

<div
class="flex flex-col pointer-events-none z-[110] absolute left-1/2 -translate-x-1/2 items-center gap-2 transition-[bottom] duration-300 ease-out"
style:bottom={bottom + 'px'}
style:padding-bottom={Math.max(0, keyboard.height - 20) + 'px'}
>
{#each $errorMessages as error (error.id)}
<div animate:flip={{ duration: 400 }} transition:fly={{ y: 80 }} class="bg-warning py-2 px-3 font-bold text-sm text-background rounded-xl w-max max-w-[85vw]">
{error.msg}
<div animate:flip={{ duration: 400 }} transition:fly={{ y: 80 }} class="flex items-center gap-2 w-max max-w-[85vw] font-bold text-sm bg-warning text-background rounded-2xl py-2.5 px-3" style:box-shadow="0px 0px 20px 0px var(--color-shadow)">
<IconAlertTriangle size={20} stroke={2} class="shrink-0" />
<span>{error.msg}</span>
{#if error.count > 1}
<span class="rounded-full px-1.5 py-0.5 text-xs leading-none shrink-0 bg-black/20">×{error.count}</span>
{/if}
</div>
{/each}
</div>
21 changes: 19 additions & 2 deletions src/lib/components/StationMenu.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@
interface Props {
bikeListHeight?: number;
posTop?: number|undefined;
anchorTop?: number|undefined;
}

let { bikeListHeight = $bindable(0), posTop = $bindable<number|undefined>(0) }: Props = $props();
let { bikeListHeight = $bindable(0), posTop = $bindable<number|undefined>(0), anchorTop = $bindable<number|undefined>(undefined) }: Props = $props();

let initPos = 0;
let pos = new Tween($selectedStation != null ? 0 : 9999, {
Expand Down Expand Up @@ -73,6 +74,7 @@
let dragging = $state(false);
let timeout:ReturnType<typeof setTimeout>;
let bikeList:HTMLDivElement;
let listWrapper:HTMLDivElement;
let menu:HTMLDivElement;
let updating = $state(false);
let windowHeight:number|undefined = $state();
Expand All @@ -86,6 +88,20 @@
}
});

// Where the sheet's top edge is headed, available while station info is
// still loading: the list is already skeleton-sized from the station's bike
// count, so the target height is known before the fetch returns. Computed
// from the list's height cap rather than a rect so the in-flight resize
// animation doesn't leak intermediate positions
$effect(() => {
if (pos.current !== null && !dragging && windowHeight !== undefined && dragged && listWrapper) {
const chrome = dragged.clientHeight - listWrapper.clientHeight;
anchorTop = Math.min(windowHeight - chrome - Math.min(windowHeight / 2, bikeListHeight) + pos.current, windowHeight);
} else {
anchorTop = undefined;
}
});

function onTouchStart(event: TouchEvent) {
initPos = event.touches[0].clientY - pos.current;
}
Expand Down Expand Up @@ -170,6 +186,7 @@
tick: (_:number) => {
dismiss();
posTop = windowHeight;
anchorTop = windowHeight;
},
};
}
Expand Down Expand Up @@ -290,7 +307,7 @@
<span class="font-bold text-[7px] text-center leading-none">{$t('free_docks_label')}</span>
</div>
</div>
<div class="overflow-y-auto transition-all" style:height="calc(min(50vh,{bikeListHeight}px))" onscroll={() => isScrolling = true} ontouchend={() => isScrolling = false}>
<div bind:this={listWrapper} class="overflow-y-auto transition-all" style:height="calc(min(50vh,{bikeListHeight}px))" onscroll={() => isScrolling = true} ontouchend={() => isScrolling = false}>
<div bind:this={bikeList} class="flex flex-col p-5 pt-2 gap-3" style:padding-bottom={$safeInsets.bottom + 'px'}>
{#if bikeInfo.length == 0}
{#each new Array(bikes) as _}
Expand Down
22 changes: 21 additions & 1 deletion src/lib/debug.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type { Position } from '@capacitor/geolocation';
import { get } from 'svelte/store';
import { currentPos, setDebugPosition } from '$lib/location';
import { following } from '$lib/map.svelte';
import { following, selectedStation, stations } from '$lib/map.svelte';
import { appSettings } from '$lib/settings';
import { shortestAngleDelta } from '$lib/marker-animation';
import { currentTrip, DEBUG_START_POSITION, toggleDebugTrip } from '$lib/trip';
import { errorMessages } from '$lib/ui.svelte';
import { token } from '$lib/account';

/** Riding a Gira, ~18 km/h. */
export const DEBUG_BIKE_SPEED_MPS = 5;
Expand Down Expand Up @@ -81,6 +83,24 @@ function debugPosition(lat: number, lng: number, heading: number|null, speed: nu

export function startDebugControls() {
if (!import.meta.env.DEV || typeof window === 'undefined') return () => undefined;

// Console hooks for exercising UI states that normally need a real account
// or a live failure (e.g. error toasts): giraDebug.fakeLogin() renders the
// main UI without credentials, giraDebug.addError() spawns a toast
const fakeJwtPayload = window.btoa(JSON.stringify({ exp: 4102444800, iat: 0, nbf: 0, jti: 'debug', sub: 'debug', loginProvider: 'debug', services: [], iss: 'debug', aud: 'debug' }));
(window as unknown as { giraDebug: unknown }).giraDebug = {
addError: (msg: string, delay?: number) => errorMessages.add(msg, delay),
fakeLogin: () => token.set({ accessToken: `debug.${fakeJwtPayload}.debug`, refreshToken: 'debug', expiration: 4102444800 }),
fakeStations: (select = true) => {
stations.value = [
{ code: '101', name: '101 - Cais do Sodré', description: null, latitude: 38.7064, longitude: -9.1449, bikes: 12, docks: 20, serialNumber: '101', assetStatus: 'active' },
{ code: '202', name: '202 - Marquês de Pombal', description: null, latitude: 38.7255, longitude: -9.1503, bikes: 5, docks: 15, serialNumber: '202', assetStatus: 'active' },
{ code: '303', name: '303 - Saldanha', description: null, latitude: 38.7336, longitude: -9.1450, bikes: 0, docks: 18, serialNumber: '303', assetStatus: 'active' },
];
if (select) selectedStation.set('101');
},
selectStation: (serial: string) => selectedStation.set(serial),
};
const held = new Set<string>;
let fastTravel = false;
let frame: number | null = null;
Expand Down
23 changes: 19 additions & 4 deletions src/lib/ui.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,27 @@ export type Insets = {
export const safeInsets = writable<Insets>({ top: 0, bottom: 0, left: 0, right: 0 });

export const errorMessages = (() => {
const { subscribe, update } = writable<{ msg: string, id: number }[]>([]);
const { subscribe, update } = writable<{ msg: string, id: number, count: number }[]>([]);
const timeouts = new Map<number, ReturnType<typeof setTimeout>>;
const remove = (id: number) => {
timeouts.delete(id);
update(messages => messages.filter(m => m.id !== id));
};
const add = async (msg: string, delay = 3000) => {
if (!(await App.getState()).isActive) return;
const id = Math.random();
update(messages => [...messages, { msg, id }].slice(-3));
setTimeout(() => update(messages => messages.filter(m => m.id !== id)), delay);
update(messages => {
// Re-adding a visible message (e.g. each attempt of a retried request)
// refreshes it and bumps its counter instead of stacking a duplicate
const existing = messages.find(m => m.msg === msg);
if (existing) {
clearTimeout(timeouts.get(existing.id));
timeouts.set(existing.id, setTimeout(() => remove(existing.id), delay));
return messages.map(m => m === existing ? { ...m, count: m.count + 1 } : m);
}
const id = Math.random();
timeouts.set(id, setTimeout(() => remove(id), delay));
return [...messages, { msg, id, count: 1 }].slice(-3);
});
};
return { subscribe, add };
})();
Expand Down
16 changes: 14 additions & 2 deletions src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@
let profileOpen = $state(false);
let locationPermission = $state(false);

// Toasts anchor to the sheet's predicted top edge (anchorTop), which tracks
// station switches immediately instead of waiting for bike info to load.
// It blanks during drags — hold the last value so toasts don't fall to the
// bottom and back — and only truly clear it during trips, when the
// unmounted menu's position is a stale snapshot
let stationMenuAnchor: number|undefined = $state();
let toastMenuPos: number|undefined = $state();
$effect(() => {
if ($currentTrip !== null) toastMenuPos = undefined;
else if (stationMenuAnchor !== undefined) toastMenuPos = stationMenuAnchor;
});

onMount(() => {
Geolocation.checkPermissions().then(({ location }) => {
locationPermission = location == 'granted';
Expand Down Expand Up @@ -84,7 +96,7 @@
{#if $currentTrip !== null}
<TripStatus bind:height={tripStatusHeight} bind:width={tripStatusWidth} />
{:else}
<StationMenu bind:posTop={stationMenuPos} bind:bikeListHeight={menuHeight} />
<StationMenu bind:posTop={stationMenuPos} bind:anchorTop={stationMenuAnchor} bind:bikeListHeight={menuHeight} />
{#if $tripRating.currentRating != null && $networkStatus }
<TripRating tripCode={$tripRating.currentRating.code} bikePlate={$tripRating.currentRating.bikePlate} date={$tripRating.currentRating.endDate} />
{/if}
Expand Down Expand Up @@ -128,4 +140,4 @@
</div>

<InfoDialog />
<ErrorMessage />
<ErrorMessage menuPos={toastMenuPos} />
Loading