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.
257 lines
9.1 KiB
Python
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()
|