diff --git a/client/scripts/feature-pairs.json b/client/scripts/feature-pairs.json
index 3d334f79f..7a630954f 100644
--- a/client/scripts/feature-pairs.json
+++ b/client/scripts/feature-pairs.json
@@ -1045,6 +1045,7 @@
"src/mobile/screens/trip/roadtrip/MRtCorridorBar.tsx": "Markup only; its corridor state comes through props from useMRtCorridor.",
"src/mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx": "Phone corridor search sheet over useMRtCorridor and the planner's shared corridor state.",
"src/mobile/screens/trip/roadtrip/MRtInfoSheet.tsx": "Read only figures sheet that already shares useHotelBookends, dayWindow and parseAvoid with RoadtripLimitsCard.",
+ "src/mobile/screens/trip/roadtrip/MRtLimitsEditor.tsx": "Large screen form inside MRtInfoSheet over the planner's saveRoadtripLimit, the desktop dialog's own save, and the shared dayWindow, parseAvoid and rangeFromSpec.",
"src/mobile/screens/trip/roadtrip/MRtKindSheet.tsx": "Thin sheet over planner.setRoadtripStopKind that already shares STOP_KINDS with StopKindPicker.",
"src/mobile/screens/trip/roadtrip/MRtStopSheet.tsx": "Phone only stop detail sheet over the shared roadtripRowModel, stayReading and planner writers.",
"src/mobile/screens/trip/roadtrip/stageBadges.ts": "Badge helpers for the phone stage header and alternatives bar, which have no desktop counterpart.",
diff --git a/client/src/mobile/screens/trip/roadtrip/MRtInfoSheet.tsx b/client/src/mobile/screens/trip/roadtrip/MRtInfoSheet.tsx
index 114961eb9..506bea338 100644
--- a/client/src/mobile/screens/trip/roadtrip/MRtInfoSheet.tsx
+++ b/client/src/mobile/screens/trip/roadtrip/MRtInfoSheet.tsx
@@ -14,6 +14,8 @@ import { formatDurationShort, parseAvoid } from '../../../../components/Roadtrip
import { valhallaAvailable } from '../../../../components/Map/valhallaRoute'
import { convertDistance, formatDistance, getDistanceUnitLabel } from '../../../../utils/units'
import { Eyebrow, TileHeader } from '../sheets/MTripSheetUi'
+import { useIsWide } from '../../../../hooks/sizeClass'
+import MRtLimitsEditor from './MRtLimitsEditor'
import type { DistanceUnit } from '../../../../types'
/**
@@ -104,6 +106,9 @@ export default function MRtInfoSheet({ planner, shell }: MTripSheetsProps) {
const degradation = useRoadtripSettings(s => s.roadtrip_battery_degradation, tripId)
const avoidRaw = useRoadtripSettings(s => s.roadtrip_avoid, tripId)
const bookends = useHotelBookends(planner.saveRoadtripLimit, tripId)
+ // An iPad or an unfolded Duo is the table the figures are set at: there the limits are a
+ // form (MRtLimitsEditor). The phone and the folded Duo keep the read-out below.
+ const large = useIsWide()
const electric = vehicleKind === 'electric'
const VehicleIcon = electric ? Zap : vehicleKind === 'combustion' ? Fuel : Car
@@ -225,26 +230,41 @@ export default function MRtInfoSheet({ planner, shell }: MTripSheetsProps) {
{/* ── Limits: what every warning on the stage is measured against ── */}
-
- {/* Under the figures it speaks of, and above the switch it does not. */}
-
- {t('mobileTrip.rtDesktopNote')}
-
+ {/* Under the figures it speaks of, and above the switch it does not. */}
+
+ {t('mobileTrip.rtDesktopNote')}
+
+ >
+ )}
{/* ── The one switch: whether a day starts and ends at the stay ── */}
diff --git a/client/src/mobile/screens/trip/roadtrip/MRtLimitsEditor.tsx b/client/src/mobile/screens/trip/roadtrip/MRtLimitsEditor.tsx
new file mode 100644
index 000000000..06c69b939
--- /dev/null
+++ b/client/src/mobile/screens/trip/roadtrip/MRtLimitsEditor.tsx
@@ -0,0 +1,350 @@
+import { useState, type ReactNode } from 'react';
+import { valhallaAvailable } from '../../../../components/Map/valhallaRoute';
+import { dayWindow } from '../../../../components/Roadtrip/dayWindow';
+import { parseAvoid, serializeAvoid, type VehicleKind } from '../../../../components/Roadtrip/roadtripModel';
+import { rangeFromSpec, type VehicleSpec } from '../../../../components/Roadtrip/vehicleRange';
+import CustomTimePicker from '../../../../components/shared/CustomTimePicker';
+import { useRoadtripSettings } from '../../../../hooks/useRoadtripSettings';
+import { useTranslation } from '../../../../i18n';
+import { useSettingsStore } from '../../../../store/settingsStore';
+import type { RouteAvoidClass } from '../../../../types';
+import { convertDistance } from '../../../../utils/units';
+import MSegmented from '../../../components/MSegmented';
+import MToggle from '../../../components/MToggle';
+import { Eyebrow } from '../sheets/MTripSheetUi';
+
+/**
+ * The driving figures as a form, for an iPad or an unfolded Duo.
+ *
+ * The phone keeps them read-only (MRtInfoSheet says why: a form in the passenger seat breaks
+ * a trip while it is happening). A large screen is the table the figures are set at, so
+ * there the same sheet edits what the desktop's limits dialog edits most: the two driving
+ * limits, the day's window and where it ends, the vehicle with its range and fill, and what
+ * the road may avoid. The vehicle's own figures (tank, consumption, battery) stay on the
+ * desktop; once they are stored the range is theirs and shows read-only here, as it does
+ * there.
+ *
+ * Every change goes through the planner's `saveRoadtripLimit`, the desktop dialog's own
+ * save, which checks the right to edit days itself. `readOnly` disables the controls for a
+ * reader without that right, so nothing takes a value only to drop it.
+ */
+
+/** Empty means no limit, and so does zero: both are stored as 0 and read as off. */
+function parseFigure(raw: string): number {
+ const n = Number(raw.replace(',', '.'));
+ return Number.isFinite(n) && n > 0 ? Math.round(n) : 0;
+}
+
+/** One card of the form: eyebrow plus its rows. */
+function EditCard({ label, children }: { label: string; children: ReactNode }) {
+ return (
+
+ {label}
+
{children}
+
+ );
+}
+
+/** A label and its control on one line, with the row rule above every row but the first. */
+function EditRow({ label, first, children }: { label: string; first?: boolean; children: ReactNode }) {
+ return (
+
+ {label}
+ {children}
+
+ );
+}
+
+/**
+ * A number typed in and saved when the field is left or Enter is pressed, not per
+ * keystroke: a settings write per digit would plan the trip against "4" on the way to "45".
+ */
+function FigureField({
+ label,
+ value,
+ suffix,
+ placeholder,
+ disabled,
+ testId,
+ onCommit,
+}: {
+ label: string;
+ value: number | undefined;
+ suffix: string;
+ placeholder: string;
+ disabled: boolean;
+ testId: string;
+ onCommit: (value: number) => void;
+}) {
+ const [draft, setDraft] = useState(null);
+ const shown = draft ?? (value ? String(value) : '');
+ const commit = () => {
+ if (draft === null) return;
+ setDraft(null);
+ const next = parseFigure(draft);
+ if (next !== (value ?? 0)) onCommit(next);
+ };
+ return (
+
+ setDraft(e.target.value.replace(/[^0-9.,]/g, ''))}
+ onBlur={commit}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') e.currentTarget.blur();
+ }}
+ className="h-9 w-[5.5rem] rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-3 text-end text-body font-semibold tabular-nums text-m-ink outline-none placeholder:font-medium placeholder:text-m-faint disabled:opacity-60"
+ />
+ {suffix}
+
+ );
+}
+
+const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
+
+const HINT = 'font-geist text-caption leading-snug text-m-faint';
+
+interface CardProps {
+ tripId: number;
+ readOnly: boolean;
+ onSave: (key: string, value: number | string | boolean) => void;
+}
+
+/** How long one stretch and one day may be, in minutes. */
+function DrivingCard({ tripId, readOnly, onSave }: CardProps) {
+ const { t } = useTranslation();
+ const leg = useRoadtripSettings((all) => all.roadtrip_leg_minutes, tripId);
+ const day = useRoadtripSettings((all) => all.roadtrip_day_minutes, tripId);
+ const rows = [
+ { key: 'roadtrip_leg_minutes', label: t('roadtrip.limit.legLabel'), value: leg, testId: 'rt-edit-leg' },
+ { key: 'roadtrip_day_minutes', label: t('roadtrip.limit.dayLabel'), value: day, testId: 'rt-edit-day' },
+ ];
+ return (
+
+ {rows.map((row, i) => (
+
+ onSave(row.key, v)}
+ />
+
+ ))}
+
{t('roadtrip.limit.hint')}
+
+ );
+}
+
+/** A choice between a few words, faded and out of reach for a reader. */
+function Choice({
+ readOnly,
+ value,
+ options,
+ onChange,
+}: {
+ readOnly: boolean;
+ value: T;
+ options: { value: T; label: string }[];
+ onChange: (value: T) => void;
+}) {
+ return (
+
+
+
+ );
+}
+
+/** When the day may start and end, and whether it ends on the road or at a stop. */
+function WindowCard({
+ tripId,
+ readOnly,
+ onSave,
+ onResetDayBoundaries,
+}: CardProps & {
+ onResetDayBoundaries?: () => Promise;
+}) {
+ const { t } = useTranslation();
+ const storedStart = useRoadtripSettings((all) => all.roadtrip_day_start, tripId) ?? '';
+ const storedEnd = useRoadtripSettings((all) => all.roadtrip_day_end, tripId) ?? '';
+ const endMode = useRoadtripSettings((all) => all.roadtrip_day_end_mode, tripId) === 'stop' ? 'stop' : 'route';
+ // What is in the pickers, ahead of a time that is whole enough to save.
+ const [times, setTimes] = useState<{ start?: string; end?: string }>({});
+ const shown = { start: times.start ?? storedStart, end: times.end ?? storedEnd };
+ const stored = { start: storedStart, end: storedEnd };
+ const invalid = !!shown.start && !!shown.end && !dayWindow(shown.start, shown.end);
+ const setTime = (field: 'start' | 'end', value: string) => {
+ setTimes((prev) => ({ ...prev, [field]: value }));
+ if (value !== '' && !TIME_RE.test(value)) return;
+ if (value !== stored[field]) onSave(`roadtrip_day_${field}`, value);
+ };
+ return (
+
+ {(['start', 'end'] as const).map((field, i) => (
+
+
+ setTime(field, value)}
+ />
+
+
+ ))}
+