Files
Garmin_Analyser/garmin_cycling_analyzer_tui.py
2025-09-26 07:30:15 -07:00

897 lines
31 KiB
Python

#!/usr/bin/env python3
"""
Garmin Cycling Analyzer TUI
A modern terminal user interface for the Garmin workout analyzer.
Requirements:
pip install textual rich
"""
import os
import sys
import asyncio
from pathlib import Path
from datetime import datetime, timedelta
from typing import List, Dict, Optional, Any
import json
import subprocess
try:
from textual.app import App, ComposeResult
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
from textual.widgets import (
Header, Footer, Button, Static, DataTable, Input, Select,
ProgressBar, Log, Tabs, TabPane, ListView, ListItem, Label,
Collapsible, Tree, Markdown, SelectionList
)
from textual.screen import Screen, ModalScreen
from textual.binding import Binding
from textual.reactive import reactive
from textual.message import Message
from textual import work
from rich.text import Text
from rich.table import Table
from rich.console import Console
from rich.markdown import Markdown as RichMarkdown
except ImportError:
print("Missing required packages. Install with:")
print("pip install textual rich")
sys.exit(1)
# Import the analyzer (assuming it's in the same directory)
try:
from garmin_cycling_analyzer import GarminWorkoutAnalyzer
except ImportError:
print("Error: Could not import GarminWorkoutAnalyzer")
print("Make sure garmin_cycling_analyzer.py is in the same directory")
sys.exit(1)
class ActivityListScreen(Screen):
"""Screen for displaying and selecting activities."""
BINDINGS = [
("escape", "app.pop_screen", "Back"),
("r", "refresh_activities", "Refresh"),
]
def __init__(self, analyzer: GarminWorkoutAnalyzer):
super().__init__()
self.analyzer = analyzer
self.activities = []
self.selected_activity = None
def compose(self) -> ComposeResult:
yield Header()
yield Container(
Static("🚴 Select Activity to Analyze", classes="title"),
Container(
Button("Refresh Activities", id="refresh_btn"),
Button("Download Latest", id="download_latest_btn"),
Button("Download All", id="download_all_btn"),
classes="button_row"
),
DataTable(id="activity_table", classes="activity_table"),
Container(
Button("Analyze Selected", id="analyze_btn", variant="primary"),
Button("View Report", id="view_report_btn"),
Button("Back", id="back_btn"),
classes="button_row"
),
classes="main_container"
)
yield Footer()
def on_mount(self) -> None:
"""Initialize the screen when mounted."""
table = self.query_one("#activity_table", DataTable)
table.add_columns("ID", "Name", "Type", "Date", "Distance", "Duration", "Status")
self.refresh_activity_list()
@work(exclusive=True)
async def refresh_activity_list(self):
"""Refresh the list of activities from Garmin Connect."""
table = self.query_one("#activity_table", DataTable)
table.clear()
# Show loading message
table.add_row("Loading...", "", "", "", "", "", "")
try:
# Connect to Garmin if not already connected
if not hasattr(self.analyzer, 'garmin_client'):
success = await asyncio.get_event_loop().run_in_executor(
None, self.analyzer.connect_to_garmin
)
if not success:
table.clear()
table.add_row("Error", "Failed to connect to Garmin", "", "", "", "", "")
return
# Get activities
activities = await asyncio.get_event_loop().run_in_executor(
None, self.analyzer.garmin_client.get_activities, 0, 50
)
table.clear()
# Filter for cycling activities
cycling_keywords = ['cycling', 'bike', 'road_biking', 'mountain_biking', 'indoor_cycling', 'biking']
cycling_activities = []
for activity in activities:
activity_type = activity.get('activityType', {})
type_key = activity_type.get('typeKey', '').lower()
type_name = str(activity_type.get('typeId', '')).lower()
activity_name = activity.get('activityName', '').lower()
if any(keyword in type_key or keyword in type_name or keyword in activity_name
for keyword in cycling_keywords):
cycling_activities.append(activity)
self.activities = cycling_activities
# Populate table
for activity in cycling_activities:
activity_id = str(activity['activityId'])
name = activity.get('activityName', 'Unnamed')
activity_type = activity.get('activityType', {}).get('typeKey', 'unknown')
start_time = activity.get('startTimeLocal', 'unknown')
distance = activity.get('distance', 0)
distance_km = f"{distance / 1000:.1f} km" if distance else "0.0 km"
duration = activity.get('duration', 0)
duration_str = str(timedelta(seconds=duration)) if duration else "0:00:00"
# Check if already downloaded
data_dir = Path("data")
existing_files = []
if data_dir.exists():
existing_files = [f for f in data_dir.glob(f"{activity_id}_*")]
# Check if report exists
report_files = []
reports_dir = Path("reports")
if reports_dir.exists():
report_files = list(reports_dir.glob(f"**/*{activity_id}*.md"))
status = "📊 Report" if report_files else "💾 Downloaded" if existing_files else "🌐 Online"
table.add_row(
activity_id, name, activity_type, start_time,
distance_km, duration_str, status
)
except Exception as e:
table.clear()
table.add_row("Error", f"Failed to load activities: {str(e)}", "", "", "", "", "")
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses."""
if event.button.id == "refresh_btn":
self.refresh_activity_list()
elif event.button.id == "back_btn":
self.app.pop_screen()
elif event.button.id == "analyze_btn":
self.analyze_selected_activity()
elif event.button.id == "view_report_btn":
self.view_selected_report()
elif event.button.id == "download_latest_btn":
self.download_latest_workout()
elif event.button.id == "download_all_btn":
self.download_all_workouts()
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
"""Handle row selection in the activity table."""
table = event.data_table
# Get the cursor row (currently selected row index)
try:
cursor_row = table.cursor_row
if 0 <= cursor_row < len(self.activities_list):
self.selected_activity = self.activities_list[cursor_row]
activity_name = self.selected_activity.get('activityName', 'Unnamed')
self.notify(f"Selected: {activity_name}", severity="information")
else:
self.selected_activity = None
except (IndexError, AttributeError):
# Fallback: try to get activity ID from the row data and find it
row_data = table.get_row(event.row_key)
if len(row_data) > 0 and row_data[0] not in ["Loading...", "Error"]:
activity_id = row_data[0]
# Find the activity in our list
for activity in self.activities:
if str(activity['activityId']) == activity_id:
self.selected_activity = activity
activity_name = activity.get('activityName', 'Unnamed')
self.notify(f"Selected: {activity_name}", severity="information")
break
else:
self.selected_activity = None
@work(exclusive=True)
async def analyze_selected_activity(self):
"""Analyze the selected activity."""
if not self.selected_activity:
self.notify("Please select an activity first", severity="warning")
return
activity_id = self.selected_activity['activityId']
# Show progress screen
progress_screen = ProgressScreen(f"Analyzing Activity {activity_id}")
self.app.push_screen(progress_screen)
try:
# Download workout
progress_screen.update_status("Downloading workout data...")
fit_file_path = await asyncio.get_event_loop().run_in_executor(
None, self.analyzer.download_specific_workout, activity_id
)
if not fit_file_path:
progress_screen.update_status("Failed to download workout", error=True)
await asyncio.sleep(2)
self.app.pop_screen()
return
progress_screen.update_status("Estimating gear configuration...")
estimated_cog = await asyncio.get_event_loop().run_in_executor(
None, self.analyzer.estimate_cog_from_cadence, fit_file_path
)
# Use default cog for indoor, or estimated for outdoor
confirmed_cog = 14 if self.analyzer.is_indoor else estimated_cog
progress_screen.update_status("Analyzing workout data...")
analysis_data = await asyncio.get_event_loop().run_in_executor(
None, self.analyzer.analyze_fit_file, fit_file_path, confirmed_cog
)
if not analysis_data:
progress_screen.update_status("Failed to analyze workout data", error=True)
await asyncio.sleep(2)
self.app.pop_screen()
return
progress_screen.update_status("Generating report...")
report_file = await asyncio.get_event_loop().run_in_executor(
None, self.analyzer.generate_markdown_report, analysis_data, activity_id
)
progress_screen.update_status(f"Analysis complete! Report saved: {report_file}", success=True)
await asyncio.sleep(2)
self.app.pop_screen()
# Refresh the activity list to update status
self.refresh_activity_list()
# Open the report viewer
self.app.push_screen(ReportViewerScreen(report_file))
except Exception as e:
progress_screen.update_status(f"Error: {str(e)}", error=True)
await asyncio.sleep(3)
self.app.pop_screen()
def view_selected_report(self):
"""View the report for the selected activity."""
if not self.selected_activity:
self.notify("Please select an activity first", severity="warning")
return
activity_id = self.selected_activity['activityId']
# Look for existing report
reports_dir = Path("reports")
if not reports_dir.exists():
self.notify("No reports directory found", severity="warning")
return
report_files = list(reports_dir.glob(f"**/*{activity_id}*.md"))
if not report_files:
self.notify(f"No report found for activity {activity_id}", severity="warning")
return
# Use the first report file found
report_file = report_files[0]
self.app.push_screen(ReportViewerScreen(str(report_file)))
@work(exclusive=True)
async def download_latest_workout(self):
"""Download the latest cycling workout."""
progress_screen = ProgressScreen("Downloading Latest Workout")
self.app.push_screen(progress_screen)
try:
progress_screen.update_status("Fetching latest cycling workout...")
fit_file_path = await asyncio.get_event_loop().run_in_executor(
None, self.analyzer.download_latest_workout
)
if fit_file_path:
progress_screen.update_status(f"Downloaded: {fit_file_path}", success=True)
else:
progress_screen.update_status("Failed to download latest workout", error=True)
await asyncio.sleep(2)
self.app.pop_screen()
self.refresh_activity_list()
except Exception as e:
progress_screen.update_status(f"Error: {str(e)}", error=True)
await asyncio.sleep(3)
self.app.pop_screen()
@work(exclusive=True)
async def download_all_workouts(self):
"""Download all cycling workouts."""
progress_screen = ProgressScreen("Downloading All Workouts")
self.app.push_screen(progress_screen)
try:
progress_screen.update_status("Downloading all cycling activities...")
await asyncio.get_event_loop().run_in_executor(
None, self.analyzer.download_all_workouts
)
progress_screen.update_status("All workouts downloaded!", success=True)
await asyncio.sleep(2)
self.app.pop_screen()
self.refresh_activity_list()
except Exception as e:
progress_screen.update_status(f"Error: {str(e)}", error=True)
await asyncio.sleep(3)
self.app.pop_screen()
def action_refresh_activities(self) -> None:
"""Refresh activities action."""
self.refresh_activity_list()
class ReportViewerScreen(Screen):
"""Screen for viewing workout reports."""
BINDINGS = [
("escape", "app.pop_screen", "Back"),
]
def __init__(self, report_file: str):
super().__init__()
self.report_file = Path(report_file)
def compose(self) -> ComposeResult:
yield Header()
yield Container(
Static(f"📊 Report: {self.report_file.name}", classes="title"),
ScrollableContainer(
Markdown(id="report_content"),
classes="report_container"
),
Container(
Button("Open in Editor", id="open_editor_btn"),
Button("Open Report Folder", id="open_folder_btn"),
Button("Back", id="back_btn"),
classes="button_row"
),
classes="main_container"
)
yield Footer()
def on_mount(self) -> None:
"""Load and display the report when mounted."""
self.load_report()
def load_report(self):
"""Load and display the report content."""
try:
if self.report_file.exists():
content = self.report_file.read_text(encoding='utf-8')
markdown_widget = self.query_one("#report_content", Markdown)
markdown_widget.update(content)
else:
self.query_one("#report_content", Markdown).update(
f"# Error\n\nReport file not found: {self.report_file}"
)
except Exception as e:
self.query_one("#report_content", Markdown).update(
f"# Error\n\nFailed to load report: {str(e)}"
)
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses."""
if event.button.id == "back_btn":
self.app.pop_screen()
elif event.button.id == "open_editor_btn":
self.open_in_editor()
elif event.button.id == "open_folder_btn":
self.open_report_folder()
def open_in_editor(self):
"""Open the report file in the default editor."""
try:
if sys.platform.startswith('darwin'): # macOS
subprocess.run(['open', str(self.report_file)])
elif sys.platform.startswith('linux'): # Linux
subprocess.run(['xdg-open', str(self.report_file)])
elif sys.platform.startswith('win'): # Windows
os.startfile(str(self.report_file))
else:
self.notify("Unsupported platform for opening files", severity="warning")
except Exception as e:
self.notify(f"Failed to open file: {str(e)}", severity="error")
def open_report_folder(self):
"""Open the report folder in the file manager."""
try:
folder = self.report_file.parent
if sys.platform.startswith('darwin'): # macOS
subprocess.run(['open', str(folder)])
elif sys.platform.startswith('linux'): # Linux
subprocess.run(['xdg-open', str(folder)])
elif sys.platform.startswith('win'): # Windows
os.startfile(str(folder))
else:
self.notify("Unsupported platform for opening folders", severity="warning")
except Exception as e:
self.notify(f"Failed to open folder: {str(e)}", severity="error")
class LocalReportsScreen(Screen):
"""Screen for viewing local report files."""
BINDINGS = [
("escape", "app.pop_screen", "Back"),
("r", "refresh_reports", "Refresh"),
]
def compose(self) -> ComposeResult:
yield Header()
yield Container(
Static("📊 Local Reports", classes="title"),
Container(
Button("Refresh", id="refresh_btn"),
Button("Re-analyze All", id="reanalyze_btn"),
classes="button_row"
),
DataTable(id="reports_table", classes="reports_table"),
Container(
Button("View Selected", id="view_btn", variant="primary"),
Button("Delete Selected", id="delete_btn", variant="error"),
Button("Back", id="back_btn"),
classes="button_row"
),
classes="main_container"
)
yield Footer()
def on_mount(self) -> None:
"""Initialize the screen when mounted."""
table = self.query_one("#reports_table", DataTable)
table.add_columns("Activity ID", "Date", "Name", "Report File", "Size")
self.refresh_reports()
def refresh_reports(self):
"""Refresh the list of local reports."""
table = self.query_one("#reports_table", DataTable)
table.clear()
reports_dir = Path("reports")
if not reports_dir.exists():
table.add_row("No reports directory found", "", "", "", "")
return
# Find all markdown report files
report_files = list(reports_dir.glob("**/*.md"))
if not report_files:
table.add_row("No reports found", "", "", "", "")
return
for report_file in sorted(report_files, key=lambda x: x.stat().st_mtime, reverse=True):
# Extract info from filename and path
filename = report_file.name
# Try to extract activity ID from filename
activity_id = "Unknown"
parts = filename.split('_')
for part in parts:
if part.isdigit() and len(part) > 8: # Garmin activity IDs are long
activity_id = part
break
# Get file stats
stat = report_file.stat()
size = f"{stat.st_size / 1024:.1f} KB"
modified_time = datetime.fromtimestamp(stat.st_mtime)
date_str = modified_time.strftime("%Y-%m-%d %H:%M")
# Try to extract workout name from parent directory
parent_name = report_file.parent.name
if parent_name != "reports":
name = parent_name
else:
name = filename.replace('.md', '').replace('_workout_analysis', '')
table.add_row(activity_id, date_str, name, str(report_file), size)
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses."""
if event.button.id == "back_btn":
self.app.pop_screen()
elif event.button.id == "refresh_btn":
self.refresh_reports()
elif event.button.id == "view_btn":
self.view_selected_report()
elif event.button.id == "delete_btn":
self.delete_selected_report()
elif event.button.id == "reanalyze_btn":
self.reanalyze_all_workouts()
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
"""Handle row selection in the reports table."""
table = event.data_table
row_key = event.row_key
row_data = table.get_row(row_key)
if len(row_data) > 3:
self.selected_report_file = row_data[3] # Report file path
def view_selected_report(self):
"""View the selected report."""
if not hasattr(self, 'selected_report_file'):
self.notify("Please select a report first", severity="warning")
return
self.app.push_screen(ReportViewerScreen(self.selected_report_file))
def delete_selected_report(self):
"""Delete the selected report."""
if not hasattr(self, 'selected_report_file'):
self.notify("Please select a report first", severity="warning")
return
# Show confirmation dialog
self.app.push_screen(ConfirmDialog(
f"Delete report?\n\n{self.selected_report_file}",
self.confirm_delete_report
))
def confirm_delete_report(self):
"""Confirm and delete the report."""
try:
report_path = Path(self.selected_report_file)
if report_path.exists():
report_path.unlink()
self.notify(f"Deleted: {report_path.name}", severity="information")
self.refresh_reports()
else:
self.notify("Report file not found", severity="warning")
except Exception as e:
self.notify(f"Failed to delete report: {str(e)}", severity="error")
@work(exclusive=True)
async def reanalyze_all_workouts(self):
"""Re-analyze all downloaded workouts."""
progress_screen = ProgressScreen("Re-analyzing All Workouts")
self.app.push_screen(progress_screen)
try:
analyzer = GarminWorkoutAnalyzer()
progress_screen.update_status("Re-analyzing all downloaded activities...")
await asyncio.get_event_loop().run_in_executor(
None, analyzer.reanalyze_all_workouts
)
progress_screen.update_status("All workouts re-analyzed!", success=True)
await asyncio.sleep(2)
self.app.pop_screen()
self.refresh_reports()
except Exception as e:
progress_screen.update_status(f"Error: {str(e)}", error=True)
await asyncio.sleep(3)
self.app.pop_screen()
def action_refresh_reports(self) -> None:
"""Refresh reports action."""
self.refresh_reports()
class ProgressScreen(ModalScreen):
"""Modal screen for showing progress of long-running operations."""
def __init__(self, title: str):
super().__init__()
self.title = title
def compose(self) -> ComposeResult:
yield Container(
Static(self.title, classes="progress_title"),
Static("Starting...", id="status_text", classes="status_text"),
ProgressBar(id="progress_bar"),
classes="progress_container"
)
def update_status(self, message: str, error: bool = False, success: bool = False):
"""Update the status message."""
status_text = self.query_one("#status_text", Static)
if error:
status_text.update(f"{message}")
elif success:
status_text.update(f"{message}")
else:
status_text.update(f"{message}")
class ConfirmDialog(ModalScreen):
"""Modal dialog for confirmation."""
def __init__(self, message: str, callback):
super().__init__()
self.message = message
self.callback = callback
def compose(self) -> ComposeResult:
yield Container(
Static(self.message, classes="dialog_message"),
Container(
Button("Yes", id="yes_btn", variant="error"),
Button("No", id="no_btn", variant="primary"),
classes="dialog_buttons"
),
classes="dialog_container"
)
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses."""
if event.button.id == "yes_btn":
self.app.pop_screen()
if self.callback:
self.callback()
else:
self.app.pop_screen()
class MainMenuScreen(Screen):
"""Main menu screen."""
BINDINGS = [
("q", "quit", "Quit"),
("1", "activities", "Activities"),
("2", "reports", "Reports"),
]
def __init__(self, analyzer: GarminWorkoutAnalyzer):
super().__init__()
self.analyzer = analyzer
def compose(self) -> ComposeResult:
yield Header()
yield Container(
Static("🚴 Garmin Cycling Analyzer TUI", classes="main_title"),
Static("Select an option:", classes="subtitle"),
Container(
Button("1. Browse & Analyze Activities", id="activities_btn", variant="primary"),
Button("2. View Local Reports", id="reports_btn"),
Button("3. Settings", id="settings_btn"),
Button("4. Quit", id="quit_btn", variant="error"),
classes="menu_buttons"
),
Static("\nKeyboard shortcuts: 1=Activities, 2=Reports, Q=Quit", classes="help_text"),
classes="main_menu_container"
)
yield Footer()
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses."""
if event.button.id == "activities_btn":
self.action_activities()
elif event.button.id == "reports_btn":
self.action_reports()
elif event.button.id == "settings_btn":
self.action_settings()
elif event.button.id == "quit_btn":
self.action_quit()
def action_activities(self) -> None:
"""Open activities screen."""
self.app.push_screen(ActivityListScreen(self.analyzer))
def action_reports(self) -> None:
"""Open reports screen."""
self.app.push_screen(LocalReportsScreen())
def action_settings(self) -> None:
"""Open settings screen."""
self.notify("Settings not implemented yet", severity="information")
def action_quit(self) -> None:
"""Quit the application."""
self.app.exit()
class GarminTUIApp(App):
"""Main TUI application."""
CSS = """
.main_title {
text-align: center;
text-style: bold;
color: $accent;
margin: 1;
}
.subtitle {
text-align: center;
margin: 1;
}
.title {
text-style: bold;
background: $primary;
color: $text;
padding: 0 1;
margin: 0 0 1 0;
}
.main_container {
margin: 1;
height: 100%;
}
.main_menu_container {
height: 100%;
align: center middle;
}
.menu_buttons {
align: center middle;
width: 60%;
}
.menu_buttons Button {
width: 100%;
margin: 0 0 1 0;
}
.button_row {
height: auto;
margin: 1 0;
}
.button_row Button {
margin: 0 1 0 0;
}
.activity_table, .reports_table {
height: 70%;
margin: 1 0;
}
.report_container {
height: 80%;
border: solid $primary;
margin: 1 0;
}
.help_text {
text-align: center;
color: $text-muted;
margin: 2 0;
}
.progress_container {
width: 60;
height: 15;
background: $surface;
border: solid $primary;
align: center middle;
}
.progress_title {
text-align: center;
text-style: bold;
margin: 1;
}
.status_text {
text-align: center;
margin: 1;
}
.dialog_container {
width: 50;
height: 15;
background: $surface;
border: solid $primary;
align: center middle;
}
.dialog_message {
text-align: center;
margin: 1;
width: 100%;
}
.dialog_buttons {
align: center middle;
width: 100%;
}
.dialog_buttons Button {
margin: 0 1;
}
"""
TITLE = "Garmin Cycling Analyzer TUI"
BINDINGS = [
Binding("ctrl+c", "quit", "Quit", show=False),
]
def on_mount(self) -> None:
"""Initialize the application."""
# Check for .env file
env_file = Path('.env')
if not env_file.exists():
self.notify("Creating .env file template. Please add your Garmin credentials.", severity="warning")
with open('.env', 'w') as f:
f.write("# Garmin Connect Credentials\n")
f.write("GARMIN_USERNAME=your_username_here\n")
f.write("GARMIN_PASSWORD=your_password_here\n")
self.exit(message="Please edit .env file with your Garmin credentials")
return
# Create directories
os.makedirs("data", exist_ok=True)
os.makedirs("reports", exist_ok=True)
# Initialize analyzer
self.analyzer = GarminWorkoutAnalyzer()
# Push main menu screen
self.push_screen(MainMenuScreen(self.analyzer))
def action_quit(self) -> None:
"""Quit the application."""
self.exit()
def main():
"""Main entry point for the TUI application."""
try:
app = GarminTUIApp()
app.run()
except KeyboardInterrupt:
print("\nExiting...")
except Exception as e:
print(f"Error running TUI: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
# Check if required dependencies are available
missing_deps = []
try:
import textual
except ImportError:
missing_deps.append("textual")
try:
import rich
except ImportError:
missing_deps.append("rich")
if missing_deps:
print("Missing required dependencies:")
for dep in missing_deps:
print(f" - {dep}")
print("\nInstall with: pip install " + " ".join(missing_deps))
sys.exit(1)
main()