feat: activity type and subtype selection at save time
New src/sports.ts mirrors bincio_activity definitions exactly: 7 sports (cycling/running/hiking/walking/swimming/skiing/other), 10 subtypes grouped by sport, icons and labels matching format.ts. PostRecordingScreen: sport grid with emoji pills, conditional subtype row (only for sports that have subtypes), subtype deselectable by tapping again. Defaults to cycling. DB: sport + sub_sport columns added; ALTER TABLE migration for existing installs (try/catch — column already exists is silently ignored). SavedRecordingsScreen: sport emoji + label + subtype shown in each card.
This commit is contained in:
@@ -7,17 +7,32 @@ import { insertRecording } from '../services/db';
|
||||
import { SavedRecording } from '../types';
|
||||
import { randomUUID } from 'expo-crypto';
|
||||
import { colors } from '../theme';
|
||||
import { useTheme } from '../ThemeContext';
|
||||
import {
|
||||
ALL_SPORTS, SPORT_ICONS, SPORT_LABELS, SPORT_SUBTYPES, SUB_SPORT_LABELS,
|
||||
type Sport, type SubSport,
|
||||
} from '../sports';
|
||||
|
||||
export function PostRecordingScreen() {
|
||||
const nav = useNavigation();
|
||||
const { accent, accentDim } = useTheme();
|
||||
const { trackPoints, reset, getStats } = useRecordingStore();
|
||||
const stats = getStats();
|
||||
const [title, setTitle] = useState('');
|
||||
const [sport, setSport] = useState<Sport>('cycling');
|
||||
const [subSport, setSubSport] = useState<SubSport | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const subtypes = SPORT_SUBTYPES[sport] ?? [];
|
||||
|
||||
function handleSportSelect(s: Sport) {
|
||||
setSport(s);
|
||||
setSubSport(null); // reset subtype when sport changes
|
||||
}
|
||||
|
||||
const formatDuration = (secs: number) => {
|
||||
const h = Math.floor(secs / 3600).toString().padStart(2, '0');
|
||||
const m = Math.floor((secs % 3600) / 60).toString().padStart(2, '0');
|
||||
const m = Math.floor((secs % 3600) / 60).toString().padStart(2, '00');
|
||||
const s = (secs % 60).toString().padStart(2, '0');
|
||||
return `${h}:${m}:${s}`;
|
||||
};
|
||||
@@ -34,6 +49,8 @@ export function PostRecordingScreen() {
|
||||
durationSeconds: stats.elapsedSeconds,
|
||||
distanceMeters: stats.distanceMeters,
|
||||
filePath,
|
||||
sport,
|
||||
subSport,
|
||||
};
|
||||
await insertRecording(rec);
|
||||
reset();
|
||||
@@ -57,12 +74,51 @@ export function PostRecordingScreen() {
|
||||
<Text style={styles.heading}>Recording complete</Text>
|
||||
|
||||
<View style={styles.statsRow}>
|
||||
<Stat label="Duration" value={formatDuration(stats.elapsedSeconds)} />
|
||||
<Stat label="Distance" value={`${(stats.distanceMeters / 1000).toFixed(2)} km`} />
|
||||
<Stat label="Duration" value={formatDuration(stats.elapsedSeconds)} />
|
||||
<Stat label="Distance" value={`${(stats.distanceMeters / 1000).toFixed(2)} km`} />
|
||||
<Stat label="Avg Speed" value={`${stats.avgSpeedKph.toFixed(1)} km/h`} />
|
||||
<Stat label="Elevation" value={`+${stats.elevationGainMeters.toFixed(0)} m`} />
|
||||
</View>
|
||||
|
||||
{/* Sport selector */}
|
||||
<Text style={styles.sectionLabel}>Activity type</Text>
|
||||
<View style={styles.sportGrid}>
|
||||
{ALL_SPORTS.map((s) => {
|
||||
const active = sport === s;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={s}
|
||||
style={[styles.sportPill, active && { borderColor: accent, backgroundColor: accentDim }]}
|
||||
onPress={() => handleSportSelect(s)}
|
||||
>
|
||||
<Text style={styles.sportIcon}>{SPORT_ICONS[s]}</Text>
|
||||
<Text style={[styles.sportLabel, active && { color: accent }]}>{SPORT_LABELS[s]}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* Subtype selector — only shown when sport has subtypes */}
|
||||
{subtypes.length > 0 && (
|
||||
<>
|
||||
<Text style={styles.sectionLabel}>Subtype</Text>
|
||||
<View style={styles.subRow}>
|
||||
{subtypes.map((sub) => {
|
||||
const active = subSport === sub;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={sub}
|
||||
style={[styles.subPill, active && { borderColor: accent, backgroundColor: accentDim }]}
|
||||
onPress={() => setSubSport(active ? null : sub)}
|
||||
>
|
||||
<Text style={[styles.subLabel, active && { color: accent }]}>{SUB_SPORT_LABELS[sub]}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Activity title"
|
||||
@@ -92,16 +148,24 @@ function Stat({ label, value }: { label: string; value: string }) {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.bg },
|
||||
content: { padding: 24, gap: 12 },
|
||||
heading: { color: colors.text, fontSize: 22, fontWeight: '700', marginBottom: 4 },
|
||||
statsRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginBottom: 4 },
|
||||
stat: { flex: 1, minWidth: '40%', backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: 10, padding: 14 },
|
||||
statLabel: { color: colors.textMuted, fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5 },
|
||||
statValue: { color: colors.text, fontSize: 22, fontWeight: '700', marginTop: 4 },
|
||||
input: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, color: colors.text, borderRadius: 10, padding: 14, fontSize: 16 },
|
||||
btn: { borderRadius: 10, padding: 16, alignItems: 'center' },
|
||||
btnSave: { backgroundColor: colors.btnStart },
|
||||
btnDiscard:{ backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border },
|
||||
btnText: { fontSize: 16, fontWeight: '600', color: colors.text },
|
||||
container: { flex: 1, backgroundColor: colors.bg },
|
||||
content: { padding: 24, gap: 12 },
|
||||
heading: { color: colors.text, fontSize: 22, fontWeight: '700', marginBottom: 4 },
|
||||
statsRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
|
||||
stat: { flex: 1, minWidth: '40%', backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: 10, padding: 14 },
|
||||
statLabel: { color: colors.textMuted, fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5 },
|
||||
statValue: { color: colors.text, fontSize: 20, fontWeight: '700', marginTop: 4 },
|
||||
sectionLabel:{ color: colors.textMuted, fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.8, marginTop: 4 },
|
||||
sportGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
|
||||
sportPill: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingVertical: 9, paddingHorizontal: 14, borderRadius: 20, borderWidth: 1, borderColor: colors.borderStrong, backgroundColor: colors.surface },
|
||||
sportIcon: { fontSize: 16 },
|
||||
sportLabel: { color: colors.textSub, fontSize: 13, fontWeight: '500' },
|
||||
subRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
|
||||
subPill: { paddingVertical: 7, paddingHorizontal: 14, borderRadius: 20, borderWidth: 1, borderColor: colors.borderStrong, backgroundColor: colors.surface },
|
||||
subLabel: { color: colors.textSub, fontSize: 13, fontWeight: '500' },
|
||||
input: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, color: colors.text, borderRadius: 10, padding: 14, fontSize: 16 },
|
||||
btn: { borderRadius: 10, padding: 16, alignItems: 'center' },
|
||||
btnSave: { backgroundColor: colors.btnStart },
|
||||
btnDiscard: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border },
|
||||
btnText: { fontSize: 16, fontWeight: '600', color: colors.text },
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { SavedRecording } from '../types';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { colors } from '../theme';
|
||||
import { useTheme } from '../ThemeContext';
|
||||
import { SPORT_ICONS, SPORT_LABELS, SUB_SPORT_LABELS, type Sport, type SubSport } from '../sports';
|
||||
|
||||
export function SavedRecordingsScreen() {
|
||||
const { accent } = useTheme();
|
||||
@@ -70,9 +71,16 @@ export function SavedRecordingsScreen() {
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.cardMeta}>
|
||||
<Text style={styles.cardTitle}>{item.title}</Text>
|
||||
<View style={styles.cardTitleRow}>
|
||||
<Text style={styles.cardIcon}>{SPORT_ICONS[item.sport as Sport] ?? '⚡'}</Text>
|
||||
<Text style={styles.cardTitle}>{item.title}</Text>
|
||||
</View>
|
||||
<Text style={styles.cardSub}>
|
||||
{new Date(item.date).toLocaleDateString()} · {formatDuration(item.durationSeconds)} · {(item.distanceMeters / 1000).toFixed(2)} km
|
||||
{SPORT_LABELS[item.sport as Sport] ?? item.sport}
|
||||
{item.subSport ? ` · ${SUB_SPORT_LABELS[item.subSport as SubSport] ?? item.subSport}` : ''}
|
||||
{' · '}{new Date(item.date).toLocaleDateString()}
|
||||
{' · '}{formatDuration(item.durationSeconds)}
|
||||
{' · '}{(item.distanceMeters / 1000).toFixed(2)} km
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.cardActions}>
|
||||
@@ -98,7 +106,9 @@ const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.bg },
|
||||
center: { flex: 1, backgroundColor: colors.bg, alignItems: 'center', justifyContent: 'center' },
|
||||
list: { padding: 16, gap: 10 },
|
||||
card: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: 12, padding: 16 },
|
||||
card: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: 12, padding: 16 },
|
||||
cardTitleRow: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 4 },
|
||||
cardIcon: { fontSize: 18 },
|
||||
cardMeta: { marginBottom: 12 },
|
||||
cardTitle: { color: colors.text, fontSize: 15, fontWeight: '600' },
|
||||
cardSub: { color: colors.textMuted, fontSize: 12, marginTop: 4 },
|
||||
|
||||
+14
-3
@@ -13,9 +13,18 @@ async function getDb(): Promise<SQLite.SQLiteDatabase> {
|
||||
date TEXT NOT NULL,
|
||||
duration_seconds INTEGER NOT NULL,
|
||||
distance_meters REAL NOT NULL,
|
||||
file_path TEXT NOT NULL
|
||||
file_path TEXT NOT NULL,
|
||||
sport TEXT NOT NULL DEFAULT 'other',
|
||||
sub_sport TEXT
|
||||
);
|
||||
`);
|
||||
// Migrate existing installs that pre-date the sport columns
|
||||
for (const stmt of [
|
||||
"ALTER TABLE recordings ADD COLUMN sport TEXT NOT NULL DEFAULT 'other'",
|
||||
'ALTER TABLE recordings ADD COLUMN sub_sport TEXT',
|
||||
]) {
|
||||
try { await db.execAsync(stmt); } catch { /* column already exists */ }
|
||||
}
|
||||
}
|
||||
return db;
|
||||
}
|
||||
@@ -23,8 +32,8 @@ async function getDb(): Promise<SQLite.SQLiteDatabase> {
|
||||
export async function insertRecording(rec: SavedRecording): Promise<void> {
|
||||
const d = await getDb();
|
||||
await d.runAsync(
|
||||
'INSERT INTO recordings (id, title, date, duration_seconds, distance_meters, file_path) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[rec.id, rec.title, rec.date, rec.durationSeconds, rec.distanceMeters, rec.filePath],
|
||||
'INSERT INTO recordings (id, title, date, duration_seconds, distance_meters, file_path, sport, sub_sport) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[rec.id, rec.title, rec.date, rec.durationSeconds, rec.distanceMeters, rec.filePath, rec.sport, rec.subSport ?? null],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,6 +47,8 @@ export async function listRecordings(): Promise<SavedRecording[]> {
|
||||
durationSeconds: r.duration_seconds,
|
||||
distanceMeters: r.distance_meters,
|
||||
filePath: r.file_path,
|
||||
sport: r.sport ?? 'other',
|
||||
subSport: r.sub_sport ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Mirror of bincio_activity sport definitions — keep in sync with
|
||||
// bincio_activity/site/src/lib/types.ts and bincio/extract/sport.py
|
||||
|
||||
export type Sport =
|
||||
| 'cycling' | 'running' | 'hiking'
|
||||
| 'walking' | 'swimming' | 'skiing' | 'other';
|
||||
|
||||
export type SubSport =
|
||||
| 'road' | 'mountain' | 'gravel' | 'indoor' // cycling
|
||||
| 'trail' | 'track' // running (+ indoor shared)
|
||||
| 'open_water' | 'pool' // swimming
|
||||
| 'nordic' | 'alpine'; // skiing
|
||||
|
||||
export const SPORT_ICONS: Record<Sport, string> = {
|
||||
cycling: '🚴',
|
||||
running: '🏃',
|
||||
hiking: '🥾',
|
||||
walking: '🚶',
|
||||
swimming: '🏊',
|
||||
skiing: '⛷️',
|
||||
other: '⚡',
|
||||
};
|
||||
|
||||
export const SPORT_LABELS: Record<Sport, string> = {
|
||||
cycling: 'Cycling',
|
||||
running: 'Running',
|
||||
hiking: 'Hiking',
|
||||
walking: 'Walking',
|
||||
swimming: 'Swimming',
|
||||
skiing: 'Skiing',
|
||||
other: 'Other',
|
||||
};
|
||||
|
||||
export const SUB_SPORT_LABELS: Record<SubSport, string> = {
|
||||
road: 'Road',
|
||||
mountain: 'MTB',
|
||||
gravel: 'Gravel',
|
||||
indoor: 'Indoor',
|
||||
trail: 'Trail',
|
||||
track: 'Track',
|
||||
nordic: 'Nordic',
|
||||
alpine: 'Alpine',
|
||||
open_water: 'Open Water',
|
||||
pool: 'Pool',
|
||||
};
|
||||
|
||||
export const SPORT_SUBTYPES: Partial<Record<Sport, SubSport[]>> = {
|
||||
cycling: ['road', 'mountain', 'gravel', 'indoor'],
|
||||
running: ['trail', 'track', 'indoor'],
|
||||
swimming: ['open_water', 'pool'],
|
||||
skiing: ['nordic', 'alpine'],
|
||||
};
|
||||
|
||||
export const ALL_SPORTS: Sport[] = [
|
||||
'cycling', 'running', 'hiking', 'walking', 'swimming', 'skiing', 'other',
|
||||
];
|
||||
@@ -35,6 +35,8 @@ export interface SavedRecording {
|
||||
durationSeconds: number;
|
||||
distanceMeters: number;
|
||||
filePath: string;
|
||||
sport: string;
|
||||
subSport: string | null;
|
||||
}
|
||||
|
||||
export type RootStackParamList = {
|
||||
|
||||
Reference in New Issue
Block a user