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 { SavedRecording } from '../types';
|
||||||
import { randomUUID } from 'expo-crypto';
|
import { randomUUID } from 'expo-crypto';
|
||||||
import { colors } from '../theme';
|
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() {
|
export function PostRecordingScreen() {
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
|
const { accent, accentDim } = useTheme();
|
||||||
const { trackPoints, reset, getStats } = useRecordingStore();
|
const { trackPoints, reset, getStats } = useRecordingStore();
|
||||||
const stats = getStats();
|
const stats = getStats();
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
|
const [sport, setSport] = useState<Sport>('cycling');
|
||||||
|
const [subSport, setSubSport] = useState<SubSport | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
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 formatDuration = (secs: number) => {
|
||||||
const h = Math.floor(secs / 3600).toString().padStart(2, '0');
|
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');
|
const s = (secs % 60).toString().padStart(2, '0');
|
||||||
return `${h}:${m}:${s}`;
|
return `${h}:${m}:${s}`;
|
||||||
};
|
};
|
||||||
@@ -34,6 +49,8 @@ export function PostRecordingScreen() {
|
|||||||
durationSeconds: stats.elapsedSeconds,
|
durationSeconds: stats.elapsedSeconds,
|
||||||
distanceMeters: stats.distanceMeters,
|
distanceMeters: stats.distanceMeters,
|
||||||
filePath,
|
filePath,
|
||||||
|
sport,
|
||||||
|
subSport,
|
||||||
};
|
};
|
||||||
await insertRecording(rec);
|
await insertRecording(rec);
|
||||||
reset();
|
reset();
|
||||||
@@ -63,6 +80,45 @@ export function PostRecordingScreen() {
|
|||||||
<Stat label="Elevation" value={`+${stats.elevationGainMeters.toFixed(0)} m`} />
|
<Stat label="Elevation" value={`+${stats.elevationGainMeters.toFixed(0)} m`} />
|
||||||
</View>
|
</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
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder="Activity title"
|
placeholder="Activity title"
|
||||||
@@ -95,13 +151,21 @@ const styles = StyleSheet.create({
|
|||||||
container: { flex: 1, backgroundColor: colors.bg },
|
container: { flex: 1, backgroundColor: colors.bg },
|
||||||
content: { padding: 24, gap: 12 },
|
content: { padding: 24, gap: 12 },
|
||||||
heading: { color: colors.text, fontSize: 22, fontWeight: '700', marginBottom: 4 },
|
heading: { color: colors.text, fontSize: 22, fontWeight: '700', marginBottom: 4 },
|
||||||
statsRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, 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 },
|
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 },
|
statLabel: { color: colors.textMuted, fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5 },
|
||||||
statValue: { color: colors.text, fontSize: 22, fontWeight: '700', marginTop: 4 },
|
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 },
|
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' },
|
btn: { borderRadius: 10, padding: 16, alignItems: 'center' },
|
||||||
btnSave: { backgroundColor: colors.btnStart },
|
btnSave: { backgroundColor: colors.btnStart },
|
||||||
btnDiscard:{ backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border },
|
btnDiscard: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border },
|
||||||
btnText: { fontSize: 16, fontWeight: '600', color: colors.text },
|
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 AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { colors } from '../theme';
|
import { colors } from '../theme';
|
||||||
import { useTheme } from '../ThemeContext';
|
import { useTheme } from '../ThemeContext';
|
||||||
|
import { SPORT_ICONS, SPORT_LABELS, SUB_SPORT_LABELS, type Sport, type SubSport } from '../sports';
|
||||||
|
|
||||||
export function SavedRecordingsScreen() {
|
export function SavedRecordingsScreen() {
|
||||||
const { accent } = useTheme();
|
const { accent } = useTheme();
|
||||||
@@ -70,9 +71,16 @@ export function SavedRecordingsScreen() {
|
|||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<View style={styles.card}>
|
<View style={styles.card}>
|
||||||
<View style={styles.cardMeta}>
|
<View style={styles.cardMeta}>
|
||||||
|
<View style={styles.cardTitleRow}>
|
||||||
|
<Text style={styles.cardIcon}>{SPORT_ICONS[item.sport as Sport] ?? '⚡'}</Text>
|
||||||
<Text style={styles.cardTitle}>{item.title}</Text>
|
<Text style={styles.cardTitle}>{item.title}</Text>
|
||||||
|
</View>
|
||||||
<Text style={styles.cardSub}>
|
<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>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.cardActions}>
|
<View style={styles.cardActions}>
|
||||||
@@ -99,6 +107,8 @@ const styles = StyleSheet.create({
|
|||||||
center: { flex: 1, backgroundColor: colors.bg, alignItems: 'center', justifyContent: 'center' },
|
center: { flex: 1, backgroundColor: colors.bg, alignItems: 'center', justifyContent: 'center' },
|
||||||
list: { padding: 16, gap: 10 },
|
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 },
|
cardMeta: { marginBottom: 12 },
|
||||||
cardTitle: { color: colors.text, fontSize: 15, fontWeight: '600' },
|
cardTitle: { color: colors.text, fontSize: 15, fontWeight: '600' },
|
||||||
cardSub: { color: colors.textMuted, fontSize: 12, marginTop: 4 },
|
cardSub: { color: colors.textMuted, fontSize: 12, marginTop: 4 },
|
||||||
|
|||||||
+14
-3
@@ -13,9 +13,18 @@ async function getDb(): Promise<SQLite.SQLiteDatabase> {
|
|||||||
date TEXT NOT NULL,
|
date TEXT NOT NULL,
|
||||||
duration_seconds INTEGER NOT NULL,
|
duration_seconds INTEGER NOT NULL,
|
||||||
distance_meters REAL 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;
|
return db;
|
||||||
}
|
}
|
||||||
@@ -23,8 +32,8 @@ async function getDb(): Promise<SQLite.SQLiteDatabase> {
|
|||||||
export async function insertRecording(rec: SavedRecording): Promise<void> {
|
export async function insertRecording(rec: SavedRecording): Promise<void> {
|
||||||
const d = await getDb();
|
const d = await getDb();
|
||||||
await d.runAsync(
|
await d.runAsync(
|
||||||
'INSERT INTO recordings (id, title, date, duration_seconds, distance_meters, file_path) VALUES (?, ?, ?, ?, ?, ?)',
|
'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.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,
|
durationSeconds: r.duration_seconds,
|
||||||
distanceMeters: r.distance_meters,
|
distanceMeters: r.distance_meters,
|
||||||
filePath: r.file_path,
|
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;
|
durationSeconds: number;
|
||||||
distanceMeters: number;
|
distanceMeters: number;
|
||||||
filePath: string;
|
filePath: string;
|
||||||
|
sport: string;
|
||||||
|
subSport: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RootStackParamList = {
|
export type RootStackParamList = {
|
||||||
|
|||||||
Reference in New Issue
Block a user