Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/src/app/(tabs)/stats/expanded-weighted-exercise.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { SingleValueStatisticsGrid } from '@/components/presentation/stats/singl
import { TimePeriodSelector } from '@/components/presentation/stats/time-period-selector';
import { TitledSection } from '@/components/presentation/stats/titled-section';
import { WeightBarChart } from '@/components/presentation/stats/weight-bar-chart';
import { PowerLineChart } from '@/components/presentation/stats/power-line-chart';
import { WeightLineChart } from '@/components/presentation/stats/weight-line-chart';
import { spacing, useAppTheme } from '@/hooks/useAppTheme';
import { useAppSelector, useAppSelectorWithArg } from '@/store';
Expand Down Expand Up @@ -65,6 +66,11 @@ function LoadedStatsFilled({ stats }: { stats: WeightedExerciseStatistics }) {
<StatCardWithTitle title={t('stats.exercise.max_weight.title')}>
<WeightLineChart statistics={stats.maxLiftedPerSessionStatistics} />
</StatCardWithTitle>
{stats.maxPowerPerSessionStatistics && (
<StatCardWithTitle title={t('stats.exercise.max_power.title')}>
<PowerLineChart statistics={stats.maxPowerPerSessionStatistics} />
</StatCardWithTitle>
)}
<StatCardWithTitle title={t('stats.exercise.1rm_progress.title')}>
<WeightLineChart statistics={stats.max1RMPerSessionStatistics} />
</StatCardWithTitle>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { spacing } from '@/hooks/useAppTheme';
import { T } from '@tolgee/react';
import { useEffect, useState } from 'react';
import { View } from 'react-native';
import Button from '@/components/presentation/foundation/gesture-wrappers/button';
import { Dialog, Portal, TextInput, useTheme } from 'react-native-paper';
import { KeyboardAvoidingView } from 'react-native-keyboard-controller';

interface PowerDialogProps {
open: boolean;
power: number | undefined;
placeholder: number | undefined;
onClose: () => void;
updatePower: (power: number | undefined) => void;
}

export default function PowerDialog(props: PowerDialogProps) {
const theme = useTheme();
const [text, setText] = useState(props.power?.toString() ?? '');

useEffect(() => {
setText(props.power?.toString() ?? '');
}, [props.open, props.power]);

const parsed = Number(text);
const isValid = !text || (Number.isInteger(parsed) && parsed >= 0);

const onSaveClick = () => {
if (!isValid) {
return;
}
props.updatePower(text ? parsed : undefined);
props.onClose();
};

return (
props.open && (
<Portal>
<KeyboardAvoidingView behavior={'height'} style={{ flex: 1, pointerEvents: props.open ? 'box-none' : 'none' }}>
<Dialog visible={props.open} onDismiss={props.onClose}>
<Dialog.Title>
<T keyName="exercise.select_power.title" />
</Dialog.Title>
<Dialog.Content>
<View style={{ gap: spacing[2] }}>
<TextInput
testID="power-input"
selectTextOnFocus
mode="outlined"
inputMode="numeric"
keyboardType="number-pad"
submitBehavior="blurAndSubmit"
returnKeyType="done"
autoFocus
value={text}
error={!isValid}
placeholder={props.placeholder?.toString()}
onChangeText={setText}
right={<TextInput.Affix text="W" />}
style={{ backgroundColor: theme.colors.elevation.level3 }}
/>
</View>
</Dialog.Content>
<Dialog.Actions>
<Button onPress={props.onClose} testID="power-skip">
<T keyName="generic.skip.button" />
</Button>
<Button onPress={onSaveClick} testID="power-save" disabled={!isValid}>
<T keyName="generic.save.button" />
</Button>
</Dialog.Actions>
</Dialog>
</KeyboardAvoidingView>
</Portal>
)
);
}
84 changes: 84 additions & 0 deletions app/src/components/presentation/stats/power-line-chart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { NumericStatisticOverTime } from '@/store/stats';
import { LineChart, lineDataItem } from 'react-native-gifted-charts';
import { View } from 'react-native';
import { spacing, useAppTheme } from '@/hooks/useAppTheme';
import { useEffect, useState } from 'react';
import { lineGraphProps } from '@/components/presentation/stats/line-graph-props';
import { useFormatDate } from '@/hooks/useFormatDate';
import { Text } from 'react-native-paper';

export function PowerLineChart({
statistics: { statistics, maxValue, minValue },
}: {
statistics: NumericStatisticOverTime;
}) {
const formatDate = useFormatDate();
const { colors } = useAppTheme();
const points: lineDataItem[] = statistics.map((stat): lineDataItem => {
const label = formatDate(stat.dateTime.toLocalDate(), {
day: 'numeric',
month: 'short',
});
return {
value: stat.value,
label,
focusedDataPointLabelComponent: () => <FocusedDatapointLabelComponent value={stat.value} label={label} />,
};
});
const [width, setWidth] = useState(0);
// On android the area chart renders poorly unless it is delayed until after initial render
const [areaChart, setAreaChart] = useState(false);
useEffect(() => {
setAreaChart(!!width);
}, [width]);
return (
<View onLayout={(e) => setWidth(e.nativeEvent.layout.width)}>
<LineChart
{...lineGraphProps(colors, width, points.length)}
showFractionalValues={false}
dataPointLabelWidth={70}
showReferenceLine1
areaChart={areaChart}
delayBeforeUnFocus={10_000}
referenceLine1Position={maxValue}
dataSet={[
{
data: points,
strokeDashArray: [1],
dataPointsColor: colors.primary,
color: colors.primary,
dataPointsRadius: 5,
startFillColor: colors.primary,
endFillColor: colors.primary,
startOpacity: 0.1,
endOpacity: 0.1,
},
]}
showDataPointLabelOnFocus
noOfSections={4}
height={100}
yAxisOffset={Math.max(Math.floor(minValue) - 10, 0)}
/>
</View>
);
}

function FocusedDatapointLabelComponent(props: { value: number; label: string }) {
const { colors } = useAppTheme();
return (
<View
style={{
alignItems: 'center',
paddingVertical: spacing[1],
backgroundColor: colors.surface,
borderRadius: 4,
borderColor: colors.outline,
borderStyle: 'solid',
borderWidth: 1,
}}
>
<Text>{props.label}</Text>
<Text>{props.value.toFixed(0)} W</Text>
</View>
);
}
7 changes: 7 additions & 0 deletions app/src/components/presentation/summary/exercise-summary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ function FilledChips(props: { exercise: RecordedExercise; showWeight: boolean })
<WeightFormat color="onSurface" weight={chip.weight} />
</>
) : undefined}
{chip.power !== undefined ? (
<SurfaceText font="text-2xs" color="onSurface">
{chip.power} W
</SurfaceText>
) : undefined}
</Chip>
));
}
Expand Down Expand Up @@ -188,6 +193,7 @@ interface WeightAndRepsChipData {
repsCompleted: number | undefined;
repTarget: number;
weight: Weight;
power: number | undefined;
}

interface PotentialSetChipData {
Expand All @@ -201,6 +207,7 @@ function getWeightAndRepsChips(exercise: RecordedWeightedExercise): WeightAndRep
repsCompleted: set.set?.repsCompleted,
repTarget: exercise.blueprint.repsTargetForSet(index).max,
weight: set.weight,
power: set.set?.power,
}));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ function DummySet(props: { maxReps: number; set: PotentialSet }) {
<PotentialSetCounter
isReadonly
repsTarget={{ min: props.maxReps, max: props.maxReps }}
trackPower={false}
onTap={() => {}}
onUpdateReps={() => {}}
onUpdateWeight={() => {}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,14 @@ export function WeightedExerciseEditor({
testID="exercise-superset"
onValueChange={(supersetWithNext) => updateExercise({ supersetWithNext })}
/>,
<SegmentedListSwitch
key="trackPower"
label={t('exercise.track_power.label')}
icon={'bolt'}
value={exercise.trackPower}
testID="exercise-track-power"
onValueChange={(trackPower) => updateExercise({ trackPower })}
/>,
<SegmentListFormElement
key={3}
label={t('exercise.progressive_overload.label')}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@ interface PotentialSetCounterProps {
previousRepCount: number | undefined;
toStartNext: boolean;
isReadonly: boolean;
trackPower: boolean;

onTap: () => void;
onUpdateWeight: (weight: Weight, applyTo: WeightAppliesTo) => void;
onUpdateReps: (reps: number | undefined) => void;
onUpdateReps: (reps: number | undefined, power: number | undefined) => void;
}

export default function PotentialSetCounter(props: PotentialSetCounterProps) {
Expand Down Expand Up @@ -148,6 +149,14 @@ export default function PotentialSetCounter(props: PotentialSetCounterProps) {
<WeightFormat weight={props.set.weight} />
</Text>
</TouchableRipple>
{props.trackPower && (
<Text
testID="repcount-power"
style={{ color: colors.onSurface, textAlign: 'center', ...font['text-sm'] }}
>
{props.set.set?.power !== undefined ? `${props.set.set.power} W` : '– W'}
</Text>
)}
</View>
<WeightDialog
open={isWeightDialogOpen}
Expand Down Expand Up @@ -199,7 +208,8 @@ export default function PotentialSetCounter(props: PotentialSetCounterProps) {
open={isRepsDialogOpen}
repTarget={maxReps}
set={props.set}
updateRepCount={(reps) => props.onUpdateReps(reps)}
showPower={props.trackPower}
updateRepCount={(reps, power) => props.onUpdateReps(reps, power)}
close={() => setIsRepsDialogOpen(false)}
/>
</Holdable>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useAppTheme } from '@/hooks/useAppTheme';
import { spacing, useAppTheme } from '@/hooks/useAppTheme';
import { PotentialSet } from '@/models/session-models';
import { T } from '@tolgee/react';
import { useEffect, useState } from 'react';
Expand All @@ -12,7 +12,8 @@ interface PotentialSetAdditionalActionsDialogProps {
open: boolean;
set: PotentialSet;
repTarget: number;
updateRepCount: (reps: number | undefined) => void;
showPower: boolean;
updateRepCount: (reps: number | undefined, power: number | undefined) => void;
close: () => void;
}

Expand All @@ -22,23 +23,33 @@ export default function PotentialSetAdditionalActionsDialog({
set,
updateRepCount,
repTarget,
showPower,
}: PotentialSetAdditionalActionsDialogProps) {
const { colors } = useAppTheme();
const originalReps = set?.set?.repsCompleted;
const originalPower = set?.set?.power;

const [repCountText, setRepCountText] = useState<string>(originalReps?.toString() ?? '');
const [powerText, setPowerText] = useState<string>(originalPower?.toString() ?? '');
const parsedRepCount = Number(repCountText);
const isValid = !repCountText || (Number.isInteger(parsedRepCount) && parsedRepCount >= 0);
const parsedPower = Number(powerText);
const isPowerValid = !powerText || (Number.isInteger(parsedPower) && parsedPower >= 0);
useEffect(() => {
setRepCountText(originalReps?.toString() ?? '');
}, [originalReps]);
if (open) {
setRepCountText(originalReps?.toString() ?? '');
setPowerText(originalPower?.toString() ?? '');
}
}, [open, originalReps, originalPower]);

const powerValue = () => (powerText && isPowerValid ? parsedPower : undefined);

const save = () => {
if (!isValid) {
if (!isValid || !isPowerValid) {
return;
}

updateRepCount(repCountText ? parsedRepCount : undefined);
updateRepCount(repCountText ? parsedRepCount : undefined, powerValue());
close();
};
return (
Expand All @@ -65,10 +76,11 @@ export default function PotentialSetAdditionalActionsDialog({
<IconButton
key={i}
mode="outlined"
disabled={!isPowerValid}
icon={() => <Text>{i}</Text>}
onPress={() => {
setRepCountText(i.toString());
updateRepCount(i);
updateRepCount(i, powerValue());
close();
}}
/>
Expand All @@ -80,15 +92,28 @@ export default function PotentialSetAdditionalActionsDialog({
icon={'close'}
onPress={() => {
setRepCountText('');
updateRepCount(undefined);
setPowerText('');
updateRepCount(undefined, undefined);
close();
}}
/>
</View>
{showPower && (
<TextInput
label={<T keyName="exercise.power.label" />}
inputMode="numeric"
value={powerText}
selectTextOnFocus
error={!isPowerValid}
onChangeText={setPowerText}
right={<TextInput.Affix text="W" />}
style={{ marginTop: spacing[2] }}
/>
)}
</Dialog.Content>
<Dialog.Actions>
<Button onPress={close}>{<T keyName="generic.cancel.button" />}</Button>
<Button disabled={!isValid} onPress={save}>
<Button disabled={!isValid || !isPowerValid} onPress={save}>
{<T keyName="generic.save.button" />}
</Button>
</Dialog.Actions>
Expand Down
Loading