feat: Implement separate home and battery screens

This commit is contained in:
2025-11-29 13:45:59 +02:00
parent 057070376b
commit ad91ec26aa
2 changed files with 342 additions and 163 deletions

View File

@@ -3,16 +3,14 @@ import type { ChargingSession } from "./types";
import { fetchSessions } from "./api"; import { fetchSessions } from "./api";
type LoadState = "idle" | "loading" | "success" | "error"; type LoadState = "idle" | "loading" | "success" | "error";
type Screen = "home" | "battery";
const App: React.FC = () => { const App: React.FC = () => {
const [screen, setScreen] = useState<Screen>("home");
const [sessions, setSessions] = useState<ChargingSession[]>([]); const [sessions, setSessions] = useState<ChargingSession[]>([]);
const [loadState, setLoadState] = useState<LoadState>("idle"); const [loadState, setLoadState] = useState<LoadState>("idle");
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [activeTab] = useState<"home" | "battery" | "trips" | "settings">(
"battery"
);
useEffect(() => { useEffect(() => {
const load = async () => { const load = async () => {
setLoadState("loading"); setLoadState("loading");
@@ -21,7 +19,8 @@ const App: React.FC = () => {
try { try {
const { sessions } = await fetchSessions(30); const { sessions } = await fetchSessions(30);
const sorted = [...sessions].sort( const sorted = [...sessions].sort(
(a, b) => new Date(b.endedAt).getTime() - new Date(a.endedAt).getTime() (a, b) =>
new Date(b.endedAt).getTime() - new Date(a.endedAt).getTime()
); );
setSessions(sorted); setSessions(sorted);
setLoadState("success"); setLoadState("success");
@@ -43,24 +42,40 @@ const App: React.FC = () => {
return latestSession.sohEnd - previousSession.sohEnd; return latestSession.sohEnd - previousSession.sohEnd;
}, [latestSession, previousSession]); }, [latestSession, previousSession]);
const sohTrendPoints = useMemo(() => {
return sessions
.slice()
.reverse()
.map((s) => ({
label: new Date(s.endedAt).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
}),
value: (s.sohStart + s.sohEnd) / 2,
}));
}, [sessions]);
const now = new Date(); const now = new Date();
return ( return (
<div className="screen"> <div
{/* STATUS BAR topmost, like Android Auto / CarPlay */} className={`screen ${
screen === "battery" ? "screen-battery-enter" : "screen-home-enter"
}`}
>
<StatusBar now={now} />
{screen === "home" ? (
<HomeScreen onOpenBattery={() => setScreen("battery")} />
) : (
<BatteryScreen
sessions={sessions}
loadState={loadState}
errorMessage={errorMessage}
latestSession={latestSession}
sohChange={sohChange}
/>
)}
{screen === "battery" && (
<BottomNav current={screen} onChange={setScreen} />
)}
</div>
);
};
/* ---------- STATUS BAR ---------- */
interface StatusBarProps {
now: Date;
}
const StatusBar: React.FC<StatusBarProps> = ({ now }) => (
<div className="status-bar"> <div className="status-bar">
<div className="status-left"> <div className="status-left">
<span className="status-time"> <span className="status-time">
@@ -83,46 +98,125 @@ const App: React.FC = () => {
<span className="status-icon">🔊</span> <span className="status-icon">🔊</span>
</div> </div>
</div> </div>
);
{/* APP HEADER */} /* ---------- HOME SCREEN ---------- */
interface HomeScreenProps {
onOpenBattery: () => void;
}
const HomeScreen: React.FC<HomeScreenProps> = ({ onOpenBattery }) => {
return (
<>
<header className="top-bar">
<div className="top-bar-left">
<span className="brand">Car Menu</span>
<span className="subtitle">Select a function</span>
</div>
</header>
{/* HOME CONTENT: VW-style icon grid */}
<main className="home-menu-content">
<div className="home-menu-grid">
{/* Row 1 */}
<button className="home-menu-tile">
<span className="home-menu-icon">📻</span>
<span className="home-menu-label">Radio</span>
</button>
<button className="home-menu-tile">
<span className="home-menu-icon">🎵</span>
<span className="home-menu-label">Media</span>
</button>
<button className="home-menu-tile">
<span className="home-menu-icon">🔗</span>
<span className="home-menu-label">App Connect</span>
</button>
<button className="home-menu-tile">
<span className="home-menu-icon"></span>
<span className="home-menu-label">Climate</span>
</button>
<button className="home-menu-tile">
<span className="home-menu-icon">📞</span>
<span className="home-menu-label">Devices</span>
</button>
<button
className="home-menu-tile home-menu-tile-battery"
onClick={onOpenBattery}
>
<span className="home-menu-icon">🔋</span>
<span className="home-menu-label">Battery</span>
</button>
<button className="home-menu-tile">
<span className="home-menu-icon">🚗</span>
<span className="home-menu-label">Car</span>
</button>
<button className="home-menu-tile">
<span className="home-menu-icon">🖼</span>
<span className="home-menu-label">Images</span>
</button>
<button className="home-menu-tile">
<span className="home-menu-icon">🔊</span>
<span className="home-menu-label">Sound</span>
</button>
<button className="home-menu-tile">
<span className="home-menu-icon"></span>
<span className="home-menu-label">Settings</span>
</button>
</div>
</main>
</>
);
};
/* ---------- BATTERY SCREEN (existing screen) ---------- */
interface BatteryScreenProps {
sessions: ChargingSession[];
loadState: LoadState;
errorMessage: string | null;
latestSession: ChargingSession | null;
sohChange: number | null;
}
const BatteryScreen: React.FC<BatteryScreenProps> = ({
sessions,
loadState,
errorMessage,
latestSession,
sohChange,
}) => {
const sohTrendPoints = useMemo(() => {
return sessions
.slice()
.reverse()
.map((s) => ({
label: new Date(s.endedAt).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
}),
value: (s.sohStart + s.sohEnd) / 2,
}));
}, [sessions]);
return (
<>
<header className="top-bar"> <header className="top-bar">
<div className="top-bar-left"> <div className="top-bar-left">
<span className="brand">Battery Health</span> <span className="brand">Battery Health</span>
<span className="subtitle">Last 30 days</span> <span className="subtitle">Last 30 days</span>
</div> </div>
<div className="top-bar-right">
<span className="status-dot" />
<span className="status-label">
{loadState === "loading"
? "Syncing…"
: loadState === "error"
? "Offline"
: "Connected"}
</span>
</div>
</header> </header>
{/* QUICK CONTROL STRIP static toggles to feel “car-like” */}
<div className="quick-strip">
<button className="quick-btn quick-btn-active">
<span className="quick-icon"></span>
<span className="quick-label">AC Auto</span>
</button>
<button className="quick-btn">
<span className="quick-icon">🔥</span>
<span className="quick-label">Seat</span>
</button>
<button className="quick-btn">
<span className="quick-icon">🌙</span>
<span className="quick-label">Night</span>
</button>
<button className="quick-btn">
<span className="quick-icon"></span>
<span className="quick-label">Eco</span>
</button>
</div>
{/* MAIN CONTENT */}
{loadState === "loading" && ( {loadState === "loading" && (
<div className="center-message">Loading charging history</div> <div className="center-message">Loading charging history</div>
)} )}
@@ -156,14 +250,16 @@ const App: React.FC = () => {
<span className="value"> <span className="value">
{latestSession ? latestSession.sohEnd.toFixed(1) : "--"}% {latestSession ? latestSession.sohEnd.toFixed(1) : "--"}%
</span> </span>
{latestSession && (
<span className="secondary"> <span className="secondary">
Session ended{" "} Session ended{" "}
{new Date(latestSession!.endedAt).toLocaleString(undefined, { {new Date(latestSession.endedAt).toLocaleString(undefined, {
weekday: "short", weekday: "short",
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
})} })}
</span> </span>
)}
</div> </div>
<div className="soh-change"> <div className="soh-change">
@@ -254,9 +350,7 @@ const App: React.FC = () => {
{sessions.map((session, index) => { {sessions.map((session, index) => {
const prev = sessions[index + 1]; const prev = sessions[index + 1];
const delta = const delta =
prev != null prev != null ? session.sohEnd - prev.sohEnd : undefined;
? session.sohEnd - prev.sohEnd
: undefined;
return ( return (
<SessionRow <SessionRow
@@ -270,47 +364,52 @@ const App: React.FC = () => {
</section> </section>
</main> </main>
)} )}
</>
);
};
{/* BOTTOM NAV static icons / labels */} /* ---------- BOTTOM NAV ---------- */
interface BottomNavProps {
current: Screen;
onChange: (screen: Screen) => void;
}
const BottomNav: React.FC<BottomNavProps> = ({ current, onChange }) => {
return (
<nav className="bottom-nav"> <nav className="bottom-nav">
<button <button
className={ className={
"nav-button " + (activeTab === "home" ? "nav-button-active" : "") "nav-button " + (current === "home" ? "nav-button-active" : "")
} }
onClick={() => onChange("home")}
> >
<span className="nav-icon">🏠</span> <span className="nav-icon">🏠</span>
<span className="nav-label">Home</span> <span className="nav-label">Home</span>
</button> </button>
<button <button
className={ className={
"nav-button " + (activeTab === "battery" ? "nav-button-active" : "") "nav-button " + (current === "battery" ? "nav-button-active" : "")
} }
onClick={() => onChange("battery")}
> >
<span className="nav-icon">🔋</span> <span className="nav-icon">🚗</span>
<span className="nav-label">Battery</span> <span className="nav-label">Car</span>
</button> </button>
<button <button className="nav-button">
className={
"nav-button " + (activeTab === "trips" ? "nav-button-active" : "")
}
>
<span className="nav-icon">🗺</span> <span className="nav-icon">🗺</span>
<span className="nav-label">Trips</span> <span className="nav-label">Navigation</span>
</button> </button>
<button <button className="nav-button">
className={
"nav-button " +
(activeTab === "settings" ? "nav-button-active" : "")
}
>
<span className="nav-icon"></span> <span className="nav-icon"></span>
<span className="nav-label">Settings</span> <span className="nav-label">Settings</span>
</button> </button>
</nav> </nav>
</div>
); );
}; };
/* ---------- CHART + SESSION ROW (unchanged) ---------- */
interface TrendPoint { interface TrendPoint {
label: string; label: string;
value: number; value: number;
@@ -332,10 +431,7 @@ const SimpleTrendChart: React.FC<SimpleTrendChartProps> = ({ points }) => {
const norm = (v: number) => { const norm = (v: number) => {
if (max === min) return 50; if (max === min) return 50;
return ( return padding + ((max - v) / (max - min)) * (100 - padding * 2);
padding +
((max - v) / (max - min)) * (100 - padding * 2)
);
}; };
const stepX = points.length > 1 ? 100 / (points.length - 1) : 50; const stepX = points.length > 1 ? 100 / (points.length - 1) : 50;
@@ -350,7 +446,11 @@ const SimpleTrendChart: React.FC<SimpleTrendChartProps> = ({ points }) => {
return ( return (
<div className="chart-wrapper"> <div className="chart-wrapper">
<svg viewBox="0 0 100 100" className="chart-svg" preserveAspectRatio="none"> <svg
viewBox="0 0 100 100"
className="chart-svg"
preserveAspectRatio="none"
>
<path d={pathD} className="chart-line" fill="none" /> <path d={pathD} className="chart-line" fill="none" />
{points.map((p, i) => { {points.map((p, i) => {
const x = i * stepX; const x = i * stepX;

View File

@@ -19,7 +19,6 @@ html,
} }
/* MAIN SCREEN LAYOUT */ /* MAIN SCREEN LAYOUT */
.screen { .screen {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -30,7 +29,6 @@ html,
padding: 4px 16px 8px; padding: 4px 16px 8px;
gap: 8px; gap: 8px;
/* whole screen scrolls */
overflow-y: auto; overflow-y: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
@@ -40,7 +38,6 @@ html,
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
margin-top: 2px; margin-top: 2px;
/* small tweak */
} }
.top-bar-left { .top-bar-left {
@@ -89,19 +86,16 @@ html,
color: #ff453a; color: #ff453a;
} }
/* CONTENT AREA just layout, NO own scrolling now */ /* CONTENT AREA */
.content { .content {
display: grid; display: grid;
grid-template-rows: auto auto minmax(0, 1fr); grid-template-rows: auto auto minmax(0, 1fr);
gap: 10px; gap: 10px;
/* small extra space above nav */
padding-bottom: 12px; padding-bottom: 12px;
} }
/* PANELS */ /* PANELS */
.panel { .panel {
background: rgba(9, 11, 22, 0.9); background: rgba(9, 11, 22, 0.9);
border-radius: 16px; border-radius: 16px;
@@ -133,7 +127,6 @@ html,
} }
/* CURRENT SOH SECTION */ /* CURRENT SOH SECTION */
.current-soh-row { .current-soh-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -215,7 +208,6 @@ html,
} }
/* Chart */ /* Chart */
.panel-chart { .panel-chart {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -262,7 +254,6 @@ html,
min-height: 0; min-height: 0;
} }
/* IMPORTANT: remove its own scrolling, let whole screen handle it */
.session-list { .session-list {
margin-top: 6px; margin-top: 6px;
display: flex; display: flex;
@@ -307,7 +298,6 @@ html,
} }
/* STATUS BAR */ /* STATUS BAR */
.status-bar { .status-bar {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -354,41 +344,7 @@ html,
opacity: 0.9; opacity: 0.9;
} }
/* QUICK STRIP */
.quick-strip {
display: flex;
gap: 8px;
margin-bottom: 2px;
}
.quick-btn {
flex: 1;
border: none;
border-radius: 999px;
padding: 4px 8px;
background: rgba(21, 24, 40, 0.9);
color: #e7e9f5;
display: flex;
align-items: center;
gap: 6px;
font-size: 0.8rem;
}
.quick-btn-active {
background: linear-gradient(120deg, #2bcbff, #5e72eb);
}
.quick-icon {
font-size: 1rem;
}
.quick-label {
white-space: nowrap;
}
/* BOTTOM NAV fixed overlay inside the car screen */ /* BOTTOM NAV fixed overlay inside the car screen */
.bottom-nav { .bottom-nav {
position: sticky; position: sticky;
bottom: 0; bottom: 0;
@@ -460,3 +416,126 @@ html,
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
/* VW-style HOME MENU GRID fills the screen under the header */
.home-menu-content {
flex: 1;
min-height: 0;
display: flex;
align-items: stretch;
justify-content: stretch;
padding: 20px 0;
}
.home-menu-grid {
flex: 1;
width: 100%;
height: 100%;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-rows: repeat(2, minmax(0, 1fr));
gap: 20px;
margin: 0;
padding: 0;
}
.home-menu-tile {
border: none;
border-radius: 10px;
padding: 10px 12px;
background: radial-gradient(circle at top, #272b37, #141722 60%, #05060a);
box-shadow:
0 6px 10px rgba(0, 0, 0, 0.6),
0 0 0 1px rgba(255, 255, 255, 0.04);
color: #f5f5f5;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 8px;
text-align: center;
cursor: default;
}
.home-menu-icon {
font-size: 1.6rem;
}
.home-menu-label {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
/* Battery tile interactive, glow on hover */
.home-menu-tile-battery {
cursor: pointer;
transition:
transform 0.25s ease,
box-shadow 0.25s ease,
background 0.25s ease,
filter 0.25s ease;
}
.home-menu-tile-battery:hover {
transform: translateY(-3px) scale(1.04);
box-shadow:
0 0 25px rgba(80, 220, 255, 0.8),
0 8px 18px rgba(0, 0, 0, 0.8);
background: radial-gradient(circle at top, #2f3f6a, #111423 65%, #05060a);
filter: drop-shadow(0 0 12px rgba(80, 220, 255, 0.7));
}
.home-menu-tile-battery:active {
transform: translateY(-1px) scale(0.99);
box-shadow:
0 0 14px rgba(80, 220, 255, 0.7),
0 4px 10px rgba(0, 0, 0, 0.8);
filter: drop-shadow(0 0 8px rgba(80, 220, 255, 0.6));
}
/* Screen enter animation */
.screen-battery-enter {
animation: battery-screen-in 300ms ease-out;
transform-origin: bottom center;
}
@keyframes battery-screen-in {
from {
opacity: 0;
transform: translateY(16px) scale(0.98);
filter: blur(2px);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
filter: blur(0);
}
}
.screen-home-enter {
animation: home-screen-in 200ms ease-out;
transform-origin: bottom center;
}
@keyframes home-screen-in {
from {
opacity: 0;
transform: translateY(16px) scale(0.98);
filter: blur(2px);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
filter: blur(0);
}
}