event-tracker/android/main.py
AI_Assistant cd9f9456e9
Initial commit: EventTracker desktop app + Android companion
Desktop: GTK3 + matplotlib app tracking arbitrary events (button ->
timestamp -> CSV), with per-event stats, trend charts, settings
(rename/add events, relocate data file), and a reset-with-backup flow.
Portable desktop launcher via a .desktop.in template + install script,
no machine-specific paths baked in.

Android: minimal Kivy v1 companion reading/writing the same CSV format,
plus buildozer build tooling isolated in a venv and a local p4a recipe
override for kivy (see CLAUDE.md for why).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 07:52:54 +00:00

250 lines
8.6 KiB
Python

"""
event-tracker-android: minimal Kivy touchscreen companion to the desktop
GTK app. Reads/writes the exact same CSV format (event,timestamp), so a
file kept in sync by FolderSync/DAVx5/etc. between devices is one shared,
consistent log — this app never needs to know about the desktop app at
all, only about the same file format.
v1 scope, deliberately: record buttons + today's totals + a simple
proportional bar. No stats popup, no event rename/add, no charts beyond
the one bar row. That's the desktop app's job — see ../README.md. This
keeps the first Android build small enough to actually get working
before piling on features.
No matplotlib: unreliable on Android through Kivy's garden packaging, so
"today's totals" render as a hand-drawn bar using Kivy's own canvas.
"""
import csv
import os
from collections import Counter
from datetime import datetime
from kivy.app import App
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.gridlayout import GridLayout
from kivy.uix.button import Button
from kivy.uix.label import Label
from kivy.uix.popup import Popup
from kivy.uix.textinput import TextInput
from kivy.uix.widget import Widget
from kivy.graphics import Color, Rectangle
from kivy.clock import Clock
try:
from android.permissions import request_permissions, Permission # noqa: F401
ON_ANDROID = True
except ImportError:
ON_ANDROID = False
# ---- Configuration — everything environment/event-specific lives here ----
DEFAULT_EVENTS = ["Cigarette", "B-event"] # must match the desktop app's config.json events to line up
COLOR_PALETTE = [(0.30, 0.45, 0.69, 1), (0.77, 0.31, 0.32, 1), (0.33, 0.66, 0.41, 1)] # RGBA, cycles per event index
TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S" # must match the desktop app's format — same file, same parser
APP_TITLE = "Event Tracker"
def event_color(index):
return COLOR_PALETTE[index % len(COLOR_PALETTE)]
# ---- local app settings: just the CSV path, stored in this app's own
# private storage (never synced — every device points at its own local
# mirror of the shared file, same design as the desktop app's CONFIG_PATH) ----
def config_path(user_data_dir):
return os.path.join(user_data_dir, "config.json")
def load_data_path(user_data_dir):
path = config_path(user_data_dir)
if os.path.exists(path):
import json
with open(path) as f:
return json.load(f).get("data_path", "")
return ""
def save_data_path(user_data_dir, data_path):
import json
os.makedirs(user_data_dir, exist_ok=True)
with open(config_path(user_data_dir), "w") as f:
json.dump({"data_path": data_path}, f, indent=2)
# ---- CSV log — same format/columns as the desktop app, on purpose ----
def ensure_csv(csv_path):
os.makedirs(os.path.dirname(csv_path), exist_ok=True)
if not os.path.exists(csv_path):
with open(csv_path, "w", newline="") as f:
csv.writer(f).writerow(["event", "timestamp"])
def load_entries(csv_path):
if not csv_path or not os.path.exists(csv_path):
return []
entries = []
with open(csv_path, newline="") as f:
reader = csv.reader(f)
next(reader, None) # skip header
for row in reader:
if len(row) < 2:
continue
try:
entries.append((row[0], datetime.strptime(row[1], TIMESTAMP_FORMAT)))
except ValueError:
continue # tolerate a malformed row (e.g. a half-written sync) rather than crashing
return sorted(entries, key=lambda e: e[1])
def append_entry(csv_path, event, ts):
ensure_csv(csv_path)
with open(csv_path, "a", newline="") as f:
csv.writer(f).writerow([event, ts.strftime(TIMESTAMP_FORMAT)])
class BarRow(Widget):
"""One proportional bar per event — today's count relative to
whichever event has the highest count today. No chart library."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.values = [] # list of (count, color)
self.bind(size=self.redraw, pos=self.redraw)
def set_values(self, values):
self.values = values
self.redraw()
def redraw(self, *_args):
self.canvas.clear()
if not self.values:
return
max_count = max((count for count, _ in self.values), default=0) or 1
n = len(self.values)
bar_width = self.width / (n * 2)
gap = bar_width
x = self.x + gap / 2
with self.canvas:
for count, color in self.values:
Color(*color)
bar_height = (count / max_count) * self.height
Rectangle(pos=(x, self.y), size=(bar_width, bar_height))
x += bar_width + gap
class SettingsPopup(Popup):
def __init__(self, current_path, on_save, **kwargs):
super().__init__(title="Settings", size_hint=(0.9, 0.5), **kwargs)
self.on_save = on_save
box = BoxLayout(orientation="vertical", spacing=10, padding=10)
box.add_widget(Label(
text="Path to the synced event_log.csv on this device "
"(check your sync app's configured destination folder):",
size_hint_y=None, height=80, halign="left", valign="top",
text_size=(self.width, None),
))
self.path_input = TextInput(text=current_path, multiline=False, size_hint_y=None, height=48)
box.add_widget(self.path_input)
save_button = Button(text="Save", size_hint_y=None, height=48)
save_button.bind(on_release=self.do_save)
box.add_widget(save_button)
self.add_widget(box)
def do_save(self, *_args):
self.on_save(self.path_input.text.strip())
self.dismiss()
class TrackerRoot(BoxLayout):
def __init__(self, **kwargs):
super().__init__(orientation="vertical", spacing=10, padding=10, **kwargs)
app = App.get_running_app()
self.user_data_dir = app.user_data_dir
self.events = list(DEFAULT_EVENTS)
self.data_path = load_data_path(self.user_data_dir)
self.button_row = BoxLayout(orientation="horizontal", spacing=10, size_hint_y=0.25)
self.add_widget(self.button_row)
self.stats_grid = GridLayout(cols=4, size_hint_y=0.2)
self.add_widget(self.stats_grid)
self.bar_row = BarRow(size_hint_y=0.35)
self.add_widget(self.bar_row)
settings_button = Button(text="Settings", size_hint_y=0.1)
settings_button.bind(on_release=self.open_settings)
self.add_widget(settings_button)
self.build_buttons()
self.refresh()
def build_buttons(self):
self.button_row.clear_widgets()
for event in self.events:
button = Button(text=f"Record\n{event}")
button.bind(on_release=lambda _w, ev=event: self.record(ev))
self.button_row.add_widget(button)
def record(self, event):
if not self.data_path:
self.open_settings()
return
append_entry(self.data_path, event, datetime.now())
self.refresh()
def open_settings(self, *_args):
SettingsPopup(self.data_path, self.on_settings_saved).open()
def on_settings_saved(self, new_path):
self.data_path = new_path
save_data_path(self.user_data_dir, new_path)
self.refresh()
def refresh(self):
entries = load_entries(self.data_path)
today = datetime.now().date()
self.stats_grid.clear_widgets()
for header in ("Event", "Total", "Avg/hr", "Today"):
self.stats_grid.add_widget(Label(text=header, bold=True))
bar_values = []
for index, event in enumerate(self.events):
stamps = sorted(ts for ev, ts in entries if ev == event)
total = len(stamps)
today_count = sum(1 for ts in stamps if ts.date() == today)
if total:
span_hours = max((stamps[-1] - stamps[0]).total_seconds() / 3600, 1)
avg_hour = f"{total / span_hours:.2f}"
else:
avg_hour = "-"
self.stats_grid.add_widget(Label(text=event))
self.stats_grid.add_widget(Label(text=str(total)))
self.stats_grid.add_widget(Label(text=avg_hour))
self.stats_grid.add_widget(Label(text=str(today_count)))
bar_values.append((today_count, event_color(index)))
self.bar_row.set_values(bar_values)
class EventTrackerApp(App):
def build(self):
self.title = APP_TITLE
if ON_ANDROID:
request_permissions([
Permission.READ_EXTERNAL_STORAGE,
Permission.WRITE_EXTERNAL_STORAGE,
])
return TrackerRoot()
if __name__ == "__main__":
EventTrackerApp().run()