Track charging sessions and daily consumption history

This commit is contained in:
TCFDONMEZ
2026-10-08 13:39:40 +03:00
parent 9a6c121d09
commit 1a37e52eda
13 changed files with 527 additions and 129 deletions
@@ -131,6 +131,7 @@ public class AbrpUploadService extends Service {
private EnergyTelemetryReader energyReader;
private ConsumptionTracker consumptionTracker;
private ConsumptionCloudClient consumptionCloudClient;
private ChargeSessionTracker chargeSessionTracker;
/**
* Keeps the unit on the phone hotspot without a trip into Settings. Attempted on the
* upload tick because that is exactly when being offline costs something, and it is
@@ -198,6 +199,7 @@ public class AbrpUploadService extends Service {
energyReader = new EnergyTelemetryReader(getApplicationContext());
consumptionTracker = ConsumptionTracker.get(this);
consumptionCloudClient = new ConsumptionCloudClient(getApplicationContext());
chargeSessionTracker = ChargeSessionTracker.get(getApplicationContext());
wifi = new WifiAutoConnect(getApplicationContext());
// The head unit's own weather service, bound for one reason: ENV_OUTSIDE_TEMPERATURE
// is not implemented on this vehicle, so the car cannot say how warm it is outside
@@ -326,7 +328,8 @@ public class AbrpUploadService extends Service {
long sampleMs = System.currentTimeMillis();
EnergySnapshot vehicle = energyReader.read(sampleMs);
consumptionTracker.sample(vehicle);
consumptionCloudClient.syncIfDue(consumptionTracker.cloudSnapshot(), sampleMs);
chargeSessionTracker.sample(vehicle);
consumptionCloudClient.syncIfDue(consumptionTracker, chargeSessionTracker, sampleMs);
// Keep local consumption history alive while another app owns the ABRP feed.
if (!settings.abrpUploadEnabled) {
@@ -0,0 +1,242 @@
package com.evsuite.abrp;
import android.content.Context;
import android.content.SharedPreferences;
import com.evsuite.hardware.telemetry.EnergySnapshot;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/** Persistent charging session meter and offline upload queue. */
final class ChargeSessionTracker {
private static final String PREFS = "charging_sessions_v1";
private static final String ACTIVE = "active";
private static final String LAST = "last_completed";
private static final String PENDING = "pending";
private static final String PRICE = "price_per_kwh";
private static final long END_GRACE_MS = 60_000L;
private static final long CURVE_INTERVAL_MS = 60_000L;
private static final int MAX_CURVE_POINTS = 720;
private static ChargeSessionTracker instance;
static synchronized ChargeSessionTracker get(Context context) {
if (instance == null) instance = new ChargeSessionTracker(context.getApplicationContext());
return instance;
}
static final class Point {
final long timestampMs;
final Float socPercent;
final float powerKw;
Point(long timestampMs, Float socPercent, float powerKw) {
this.timestampMs = timestampMs; this.socPercent = socPercent; this.powerKw = powerKw;
}
}
static final class Snapshot {
final boolean charging;
final long startedAtMs;
final long durationSeconds;
final Float startSocPercent;
final Float currentSocPercent;
final double energyKwh;
final double pricePerKwh;
final double totalCost;
final List<Point> points;
Snapshot(boolean charging, long startedAtMs, long durationSeconds,
Float startSocPercent, Float currentSocPercent, double energyKwh,
double pricePerKwh, List<Point> points) {
this.charging = charging;
this.startedAtMs = startedAtMs;
this.durationSeconds = durationSeconds;
this.startSocPercent = startSocPercent;
this.currentSocPercent = currentSocPercent;
this.energyKwh = energyKwh;
this.pricePerKwh = pricePerKwh;
this.totalCost = energyKwh * pricePerKwh;
this.points = points;
}
}
private final SharedPreferences prefs;
private Session active;
private long endCandidateMs;
private ChargeSessionTracker(Context context) {
prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
active = Session.fromJson(prefs.getString(ACTIVE, null));
}
synchronized void sample(EnergySnapshot snapshot) {
long now = snapshot.getTimestampMs();
Float power = snapshot.getBatteryPowerKw();
Float soc = snapshot.getSocPercent();
boolean charging = ChargingState.isCharging(snapshot.getChargingStatus(),
snapshot.getChargePortConnected(), power, snapshot.getSpeedKmh());
if (charging) {
endCandidateMs = 0L;
if (active == null) active = new Session(UUID.randomUUID().toString(), now, soc);
active.add(now, soc, power);
persistActive();
return;
}
if (active == null) return;
if (endCandidateMs == 0L) endCandidateMs = now;
if (now - endCandidateMs < END_GRACE_MS) return;
active.finish(endCandidateMs, soc, pricePerKwh());
JSONObject completed = active.toJson();
enqueue(completed);
prefs.edit().putString(LAST, completed.toString()).commit();
active = null;
endCandidateMs = 0L;
prefs.edit().remove(ACTIVE).commit();
}
synchronized Snapshot snapshot() {
Session session = active != null ? active : Session.fromJson(prefs.getString(LAST, null));
if (session == null) return new Snapshot(false, 0L, 0L, null, null,
0d, pricePerKwh(), new ArrayList<>());
long end = session.endedAtMs > 0 ? session.endedAtMs : session.lastSampleMs;
double price = active != null ? pricePerKwh() : session.pricePerKwh;
return new Snapshot(active != null, session.startedAtMs,
Math.max(0L, (end - session.startedAtMs) / 1000L), session.startSoc,
session.lastSoc, session.energyKwh, price, new ArrayList<>(session.points));
}
synchronized boolean setPricePerKwh(double value) {
if (!Double.isFinite(value) || value < 0d || value > 1_000_000d) return false;
return prefs.edit().putLong(PRICE, Double.doubleToRawLongBits(value)).commit();
}
synchronized double pricePerKwh() {
return Double.longBitsToDouble(prefs.getLong(PRICE, 0L));
}
synchronized List<JSONObject> pendingUploads() {
JSONArray array = pendingArray();
List<JSONObject> result = new ArrayList<>();
for (int i = 0; i < array.length(); i++) {
JSONObject item = array.optJSONObject(i);
if (item != null) result.add(item);
}
return result;
}
synchronized void markUploaded(String sessionId) {
JSONArray old = pendingArray();
JSONArray kept = new JSONArray();
for (int i = 0; i < old.length(); i++) {
JSONObject item = old.optJSONObject(i);
if (item != null && !sessionId.equals(item.optString("sessionId"))) kept.put(item);
}
prefs.edit().putString(PENDING, kept.toString()).commit();
}
private void persistActive() {
prefs.edit().putString(ACTIVE, active.toJson().toString()).commit();
}
private void enqueue(JSONObject session) {
JSONArray array = pendingArray();
array.put(session);
prefs.edit().putString(PENDING, array.toString()).commit();
}
private JSONArray pendingArray() {
try { return new JSONArray(prefs.getString(PENDING, "[]")); }
catch (Exception ignored) { return new JSONArray(); }
}
private static final class Session {
final String id;
final long startedAtMs;
final Float startSoc;
final List<Point> points = new ArrayList<>();
long lastSampleMs;
Float lastSoc;
Float lastPower;
double energyKwh;
long endedAtMs;
double pricePerKwh;
Session(String id, long startedAtMs, Float startSoc) {
this.id = id; this.startedAtMs = startedAtMs; this.startSoc = startSoc;
this.lastSampleMs = startedAtMs; this.lastSoc = startSoc;
}
void add(long now, Float soc, Float power) {
if (now > lastSampleMs && power != null && lastPower != null) {
double hours = (now - lastSampleMs) / 3_600_000d;
energyKwh += Math.max(0d, -(power + lastPower) / 2d) * hours;
}
if (now > lastSampleMs) lastSampleMs = now;
if (soc != null) lastSoc = soc;
if (power != null) lastPower = power;
if (power != null && power < ChargingState.CHARGING_POWER_KW &&
(points.isEmpty() || now - points.get(points.size() - 1).timestampMs >= CURVE_INTERVAL_MS)) {
points.add(new Point(now, soc, Math.abs(power)));
if (points.size() > MAX_CURVE_POINTS) points.remove(0);
}
}
void finish(long endedAtMs, Float finalSoc, double price) {
this.endedAtMs = endedAtMs;
if (finalSoc != null) lastSoc = finalSoc;
pricePerKwh = price;
}
JSONObject toJson() {
try {
JSONArray curve = new JSONArray();
for (Point point : points) {
JSONObject p = new JSONObject().put("timestampMs", point.timestampMs)
.put("powerKw", point.powerKw);
if (point.socPercent != null) p.put("socPercent", point.socPercent);
curve.put(p);
}
JSONObject json = new JSONObject().put("sessionId", id).put("startedAt", startedAtMs)
.put("endedAt", endedAtMs).put("durationSeconds",
Math.max(0L, ((endedAtMs > 0 ? endedAtMs : lastSampleMs) - startedAtMs) / 1000L))
.put("energyKwh", energyKwh).put("pricePerKwh", pricePerKwh)
.put("totalCost", energyKwh * pricePerKwh).put("curve", curve)
.put("lastSampleMs", lastSampleMs);
if (startSoc != null) json.put("startSocPercent", startSoc);
if (lastSoc != null) json.put("endSocPercent", lastSoc);
if (lastPower != null) json.put("lastPower", lastPower);
return json;
} catch (org.json.JSONException e) {
throw new IllegalStateException("Could not serialize charging session", e);
}
}
static Session fromJson(String raw) {
if (raw == null || raw.isEmpty()) return null;
try {
JSONObject json = new JSONObject(raw);
Session session = new Session(json.getString("sessionId"), json.getLong("startedAt"),
json.has("startSocPercent") ? (float) json.getDouble("startSocPercent") : null);
session.lastSampleMs = json.optLong("lastSampleMs", session.startedAtMs);
session.lastSoc = json.has("endSocPercent") ? (float) json.getDouble("endSocPercent") : session.startSoc;
session.lastPower = json.has("lastPower") ? (float) json.getDouble("lastPower") : null;
session.energyKwh = json.optDouble("energyKwh", 0d);
session.endedAtMs = json.optLong("endedAt", 0L);
session.pricePerKwh = json.optDouble("pricePerKwh", 0d);
JSONArray curve = json.optJSONArray("curve");
if (curve != null) for (int i = 0; i < curve.length(); i++) {
JSONObject p = curve.getJSONObject(i);
session.points.add(new Point(p.getLong("timestampMs"),
p.has("socPercent") ? (float) p.getDouble("socPercent") : null,
(float) p.getDouble("powerKw")));
}
return session;
} catch (Exception ignored) { return null; }
}
}
}
@@ -22,6 +22,16 @@ public final class ChargingGraphView extends View {
points.add(new float[]{soc,p}); if (points.size() > 500) points.remove(0);
socPower[Math.max(0, Math.min(100, Math.round(soc)))] = p; invalidate();
}
synchronized void setPoints(List<ChargeSessionTracker.Point> values) {
points.clear();
java.util.Arrays.fill(socPower, Float.NaN);
for (ChargeSessionTracker.Point value : values) {
if (value.socPercent == null) continue;
points.add(new float[]{value.socPercent, value.powerKw});
socPower[Math.max(0, Math.min(100, Math.round(value.socPercent)))] = value.powerKw;
}
invalidate();
}
synchronized String tableText() {
StringBuilder out = new StringBuilder("SOC Şarj gücü\n");
boolean found = false;
@@ -11,6 +11,8 @@ import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
/** Best-effort MongoDB sync through the API in server/. Never blocks local persistence. */
@@ -20,16 +22,18 @@ final class ConsumptionCloudClient {
private static final int TIMEOUT_MS = 8_000;
private static final String PREFS = "consumption_cloud";
private static final String INSTALLATION_ID = "installation_id";
private static final String LAST_CONSUMPTION_DATE = "last_consumption_date";
private final String endpoint;
private final String token;
private final String installationId;
private final SharedPreferences prefs;
private long lastAttemptMs;
ConsumptionCloudClient(Context context) {
endpoint = BuildConfig.CONSUMPTION_API_URL.trim();
token = BuildConfig.CONSUMPTION_API_TOKEN.trim();
SharedPreferences prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
String existing = prefs.getString(INSTALLATION_ID, "");
if (existing == null || existing.isEmpty()) {
existing = UUID.randomUUID().toString();
@@ -38,19 +42,55 @@ final class ConsumptionCloudClient {
installationId = existing;
}
void syncIfDue(ConsumptionTracker.CloudSnapshot snapshot, long nowMs) {
void syncIfDue(ConsumptionTracker tracker, ChargeSessionTracker charges, long nowMs) {
if (endpoint.isEmpty() || token.isEmpty() || nowMs - lastAttemptMs < SYNC_INTERVAL_MS) return;
lastAttemptMs = nowMs;
try {
syncDailyConsumption(tracker, nowMs);
syncPendingCharges(charges);
} catch (Exception e) {
Log.w(TAG, "Cloud sync unavailable");
}
}
private void syncDailyConsumption(ConsumptionTracker tracker, long nowMs) throws Exception {
String saved = prefs.getString(LAST_CONSUMPTION_DATE, "");
LocalDate lastUploaded = null;
try { if (saved != null && !saved.isEmpty()) lastUploaded = LocalDate.parse(saved); }
catch (RuntimeException ignored) { }
LocalDate target = tracker.nextStoredDayAfter(lastUploaded);
if (target == null) return;
ConsumptionTracker.CloudSnapshot snapshot = tracker.cloudSnapshot(target);
JSONObject body = new JSONObject()
.put("installationId", installationId)
.put("recordedAt", nowMs)
.put("date", snapshot.date)
.put("day", totals(snapshot.day))
.put("lifetime", totals(snapshot.lifetime));
if (snapshot.sohPercent != null) body.put("sohPercent", snapshot.sohPercent);
if (send("/v1/consumption/daily", "PUT", body)) {
prefs.edit().putString(LAST_CONSUMPTION_DATE, target.toString()).commit();
}
}
private void syncPendingCharges(ChargeSessionTracker charges) throws Exception {
List<JSONObject> pending = charges.pendingUploads();
int sent = 0;
for (JSONObject session : pending) {
JSONObject body = new JSONObject(session.toString()).put("installationId", installationId);
String id = session.getString("sessionId");
if (!send("/v1/charging-sessions/" + id, "PUT", body)) return;
charges.markUploaded(id);
if (++sent >= 3) return;
}
}
private boolean send(String path, String method, JSONObject body) throws Exception {
HttpURLConnection connection = null;
try {
JSONObject body = new JSONObject()
.put("installationId", installationId)
.put("recordedAt", nowMs)
.put("lifetime", totals(snapshot.lifetime));
if (snapshot.sohPercent != null) body.put("sohPercent", snapshot.sohPercent);
byte[] bytes = body.toString().getBytes(StandardCharsets.UTF_8);
connection = (HttpURLConnection) new URL(trimSlash(endpoint) + "/v1/consumption").openConnection();
connection.setRequestMethod("PUT");
connection = (HttpURLConnection) new URL(trimSlash(endpoint) + path).openConnection();
connection.setRequestMethod(method);
connection.setConnectTimeout(TIMEOUT_MS);
connection.setReadTimeout(TIMEOUT_MS);
connection.setDoOutput(true);
@@ -59,11 +99,13 @@ final class ConsumptionCloudClient {
connection.setRequestProperty("Authorization", "Bearer " + token);
try (OutputStream out = connection.getOutputStream()) { out.write(bytes); }
int status = connection.getResponseCode();
try (InputStream ignored = status < 400
? connection.getInputStream() : connection.getErrorStream()) { }
if (status < 200 || status >= 300) Log.w(TAG, "Sync rejected with HTTP " + status);
} catch (Exception e) {
Log.w(TAG, "Consumption sync unavailable");
InputStream response = status < 400 ? connection.getInputStream() : connection.getErrorStream();
if (response != null) try (InputStream ignored = response) { }
if (status < 200 || status >= 300) {
Log.w(TAG, "Sync rejected with HTTP " + status);
return false;
}
return true;
} finally {
if (connection != null) connection.disconnect();
}
@@ -139,15 +139,35 @@ final class ConsumptionTracker {
return loadTotals(dayKey(date));
}
synchronized CloudSnapshot cloudSnapshot() {
return new CloudSnapshot(lifetime.snapshot(), sohPercent());
synchronized CloudSnapshot cloudSnapshot(LocalDate date) {
rollDayIfNeeded();
return new CloudSnapshot(date.toString(), totalsForDay(date), lifetime.snapshot(), sohPercent());
}
synchronized LocalDate nextStoredDayAfter(LocalDate lastUploaded) {
rollDayIfNeeded();
LocalDate latestComplete = LocalDate.now().minusDays(1);
LocalDate best = null;
for (String key : prefs.getAll().keySet()) {
if (!key.startsWith("day_") || key.length() < 14) continue;
try {
LocalDate date = LocalDate.parse(key.substring(4, 14));
if (date.isAfter(latestComplete) || (lastUploaded != null && !date.isAfter(lastUploaded))) continue;
if (best == null || date.isBefore(best)) best = date;
} catch (RuntimeException ignored) { }
}
return best;
}
static final class CloudSnapshot {
final String date;
final Totals day;
final Totals lifetime;
final Float sohPercent;
CloudSnapshot(Totals lifetime, Float sohPercent) {
CloudSnapshot(String date, Totals day, Totals lifetime, Float sohPercent) {
this.date = date;
this.day = day;
this.lifetime = lifetime;
this.sohPercent = sohPercent;
}
@@ -118,11 +118,12 @@ public class MainActivity extends AppCompatActivity {
private TextInputLayout consumptionSohLayout;
private TextInputEditText consumptionSohInput;
private java.time.LocalDate selectedConsumptionDate = java.time.LocalDate.now();
private TextView chargeStatus, chargeSoc, chargeVoltage, chargeCurrent, chargePower,
chargeEnergy, chargeDuration, chargeCurveTable;
private TextView chargeStatus, chargeSoc, chargeStartSoc, chargeVoltage, chargeCurrent, chargePower,
chargeEnergy, chargeDuration, chargeTotalCost, chargeCurveTable;
private TextInputLayout chargePriceLayout;
private TextInputEditText chargePriceInput;
private ChargeSessionTracker chargeSessionTracker;
private ChargingGraphView chargeGraph;
private long chargeStartMs, lastChargeMs;
private double chargedKwh;
/**
* Inflates a page with no parent, then gives it the MATCH_PARENT/MATCH_PARENT layout
@@ -927,40 +928,58 @@ public class MainActivity extends AppCompatActivity {
}
private void bindChargingPane() {
chargeSessionTracker = ChargeSessionTracker.get(this);
chargeStatus = chargingPane.findViewById(R.id.charge_status);
chargeSoc = chargingPane.findViewById(R.id.charge_soc);
chargeStartSoc = chargingPane.findViewById(R.id.charge_start_soc);
chargeVoltage = chargingPane.findViewById(R.id.charge_voltage);
chargeCurrent = chargingPane.findViewById(R.id.charge_current);
chargePower = chargingPane.findViewById(R.id.charge_power);
chargeEnergy = chargingPane.findViewById(R.id.charge_energy);
chargeDuration = chargingPane.findViewById(R.id.charge_duration);
chargeTotalCost = chargingPane.findViewById(R.id.charge_total_cost);
chargeGraph = chargingPane.findViewById(R.id.charge_graph);
chargeCurveTable = chargingPane.findViewById(R.id.charge_curve_table);
chargePriceLayout = chargingPane.findViewById(R.id.charge_price_layout);
chargePriceInput = chargingPane.findViewById(R.id.charge_price_input);
chargePriceInput.setText(String.format(java.util.Locale.US, "%.2f",
chargeSessionTracker.pricePerKwh()));
chargingPane.findViewById(R.id.charge_price_save).setOnClickListener(v -> saveChargePrice());
}
private void saveChargePrice() {
String raw = textOf(chargePriceInput).replace(',', '.');
try {
double value = Double.parseDouble(raw);
if (!chargeSessionTracker.setPricePerKwh(value)) throw new NumberFormatException();
chargePriceLayout.setError(null);
chargePriceInput.setText(String.format(java.util.Locale.US, "%.2f", value));
Toast.makeText(this, "Şarj fiyatı kaydedildi", Toast.LENGTH_SHORT).show();
} catch (NumberFormatException e) {
chargePriceLayout.setError("Geçerli bir ₺/kWh değeri girin");
}
}
private void refreshCharging(EnergySnapshot s) {
if (chargeStatus == null) return;
chargeSessionTracker.sample(s);
Integer code = s.getChargingStatus();
boolean charging = ChargingState.isCharging(
code, s.getChargePortConnected(), s.getBatteryPowerKw(), s.getSpeedKmh());
long now = s.getTimestampMs();
if (charging && chargeStartMs == 0) { chargeStartMs = now; chargedKwh = 0; }
if (!charging) { chargeStartMs = 0; lastChargeMs = 0; chargedKwh = 0; }
if (charging && lastChargeMs > 0 && s.getBatteryPowerKw() != null)
chargedKwh += Math.max(0, -s.getBatteryPowerKw()) * (now-lastChargeMs) / 3_600_000d;
if (charging) lastChargeMs = now;
chargeStatus.setText(charging ? (code != null && code == 10 ? "DC hızlı şarj" : "AC şarj") : "Şarj yok");
ChargeSessionTracker.Snapshot session = chargeSessionTracker.snapshot();
chargeStatus.setText(charging ? (code != null && code == 10 ? "DC hızlı şarj" : "AC şarj")
: (session.startedAtMs > 0 ? "Son şarj" : "Şarj yok"));
chargeSoc.setText(fmt(s.getSocPercent(), "%.1f %%"));
chargeStartSoc.setText(fmt(session.startSocPercent, "%.1f %%"));
chargeVoltage.setText(fmt(s.getBatteryVoltageV(), "%.1f V"));
chargeCurrent.setText(fmt(s.getBatteryCurrentA(), "%.1f A"));
chargePower.setText(s.getBatteryPowerKw() == null ? "—" : String.format(java.util.Locale.getDefault(), "%.1f kW", Math.abs(s.getBatteryPowerKw())));
chargeEnergy.setText(charging ? String.format(java.util.Locale.getDefault(), "%.2f kWh", chargedKwh) : "—");
long sec = chargeStartMs == 0 ? 0 : (now-chargeStartMs)/1000;
chargeDuration.setText(charging ? String.format(java.util.Locale.getDefault(), "%02d:%02d:%02d", sec/3600,(sec%3600)/60,sec%60) : "—");
if (charging) {
chargeGraph.add(s.getSocPercent(), s.getBatteryPowerKw());
chargeCurveTable.setText(chargeGraph.tableText());
}
chargeEnergy.setText(session.startedAtMs > 0 ? String.format(java.util.Locale.getDefault(), "%.2f kWh", session.energyKwh) : "—");
long sec = session.durationSeconds;
chargeDuration.setText(session.startedAtMs > 0 ? String.format(java.util.Locale.getDefault(), "%02d:%02d:%02d", sec/3600,(sec%3600)/60,sec%60) : "—");
chargeTotalCost.setText(session.startedAtMs > 0 ? String.format(java.util.Locale.getDefault(), "%.2f ₺", session.totalCost) : "—");
chargeGraph.setPoints(session.points);
chargeCurveTable.setText(chargeGraph.tableText());
}
private void refreshConsumption() {
+10 -1
View File
@@ -1,17 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true">
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="20dp" android:background="@drawable/bg_glass">
<TextView android:id="@+id/charge_status" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Şarj yok" android:textAppearance="@style/Text.EV.Title" android:textColor="@color/ev_cyan"/>
<GridLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:columnCount="2" android:layout_marginTop="12dp">
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="SOC" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_soc" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="—" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="Başlangıç SOC" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_start_soc" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="—" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="Batarya voltajı" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_voltage" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="—" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="Batarya akımı" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_current" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="—" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="Şarj gücü" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_power" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="—" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="Alınan enerji" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_energy" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="—" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="Şarj süresi" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_duration" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="—" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="Toplam ücret" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_total_cost" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="—" android:textAppearance="@style/DataValue"/>
</GridLayout>
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="20dp" android:layout_marginTop="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="ŞARJ FİYATI" android:textAppearance="@style/Text.EV.Eyebrow"/>
<com.google.android.material.textfield.TextInputLayout android:id="@+id/charge_price_layout" style="@style/Widget.Material3.TextInputLayout.OutlinedBox" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:hint="Birim fiyat" app:suffixText="₺/kWh">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/charge_price_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="numberDecimal" android:maxLength="10" android:minHeight="72dp"/>
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton android:id="@+id/charge_price_save" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="8dp" android:text="Fiyatı kaydet"/>
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="20dp" android:layout_marginTop="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="ŞARJ EĞRİSİ" android:textAppearance="@style/Text.EV.Eyebrow"/>
<com.evsuite.abrp.ChargingGraphView android:id="@+id/charge_graph" android:layout_width="match_parent" android:layout_height="280dp" android:layout_marginTop="12dp"/>