event-tracker/android/main.py
AI_Assistant d276f8842f
Restore filetype dep, fix Settings label wrapping to 1px column
python_depends was emptied too aggressively in an earlier fix (dodging
the requests/charset-normalizer Android-wheel bug) and took filetype
with it — kivy/core/image imports it directly, causing a startup crash.
Restored just filetype (pure Python, no dependency chain, safe).

Settings popup's info label set text_size=(self.width, None) at
__init__ time, before layout ever runs — self was the Popup at its
default ~100px pre-layout width, not its real on-screen size, so the
label rendered as a 1-character-wide column. Now binds text_size to
the label's own width so it updates once real layout happens.
2026-08-22 12:07:30 +00:00

257 lines
9.1 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)
info_label = 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 can't just be set once here: at __init__ time this Popup
# hasn't been laid out yet, so self.width is Kivy's default ~100px
# widget width, not the popup's real on-screen size — that's what
# was squeezing this label into a thin vertical column. Binding to
# the label's own `width` keeps text_size in sync as the box (and
# therefore this label, which fills it) actually gets sized.
info_label.bind(width=lambda inst, w: setattr(inst, "text_size", (w, None)))
box.add_widget(info_label)
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()