Files
studysprint/app/(tabs)/timer.tsx
2026-04-23 16:09:24 +02:00

445 lines
12 KiB
TypeScript

import * as React from 'react';
import {
Animated,
Dimensions,
StatusBar,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
const { width, height } = Dimensions.get('window');
const colors = {
black: '#323F4E',
red: '#F76A6A',
text: '#ffffff',
};
const timers = [...Array(13).keys()].map((i) => (i === 0 ? 1 : i * 5));
const ITEM_SIZE = width * 0.38;
const ITEM_SPACING = (width - ITEM_SIZE) / 2;
const TIMER_UNIT_IN_SECONDS = 1; // Set to 60 for timer value to represent minutes
const placeholderTask = {
name: 'Read chapter 4',
description: 'Focus on the summary questions and write down anything unclear.',
};
function formatTime(totalSeconds: number) {
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes.toString().padStart(2, '0')}:${
seconds.toString().padStart(2, '0')}`;
}
export default function App() {
const [containerHeight, setContainerHeight] = React.useState(0)
const [duration, setDuration] = React.useState(timers[0])
const [isRunning, setIsRunning] = React.useState(false)
const [timeRemaining, setTimeRemaining] = React.useState(0)
const [selectedIndex, setSelectedIndex] = React.useState(0)
const [showCountdownText, setShowCountdownText] = React.useState(false)
const scrollX = React.useRef(new Animated.Value(0)).current;
const timerAnimation = React.useRef(new Animated.Value(0)).current;
const buttonAnimation = React.useRef(new Animated.Value(0)).current;
const taskDetailsAnimation = React.useRef(new Animated.Value(0)).current;
const countdownAnimation = React.useRef(new Animated.Value(0)).current;
const countdownRef = React.useRef<ReturnType<typeof setInterval> | null>(null);
const runningAnimationRef = React.useRef<Animated.CompositeAnimation | null>(null);
const cancelButtonAnimation = React.useRef(new Animated.Value(0)).current;
const animation = React.useCallback(() => {
if (isRunning || containerHeight === 0) {
return;
}
setIsRunning(true);
setShowCountdownText(true);
taskDetailsAnimation.setValue(0);
countdownAnimation.setValue(0);
const totalSeconds = duration * TIMER_UNIT_IN_SECONDS;
setTimeRemaining(totalSeconds);
if (countdownRef.current) {
clearInterval(countdownRef.current);
countdownRef.current = null;
}
countdownRef.current = setInterval(() => {
setTimeRemaining((currentTime) => {
if (currentTime <= 1) {
if (countdownRef.current) {
clearInterval(countdownRef.current);
countdownRef.current = null;
}
return 0;
}
return currentTime -1;
});
}, 1000);
const runningAnimation = Animated.sequence([
Animated.parallel([
Animated.timing(buttonAnimation, {
toValue: 1,
duration: 300,
useNativeDriver: true
}),
Animated.timing(cancelButtonAnimation, {
toValue: 1,
duration: 300,
useNativeDriver: true
}),
Animated.timing(taskDetailsAnimation, {
toValue: 1,
duration: 500,
useNativeDriver: true
}),
Animated.timing(countdownAnimation, {
toValue: 1,
duration: 300,
useNativeDriver: true
}),
]),
Animated.timing(timerAnimation, {
toValue: 0,
duration: 300,
useNativeDriver: true
}),
Animated.timing(timerAnimation, {
toValue: containerHeight,
duration: totalSeconds * 1000,
useNativeDriver: true
})
]);
runningAnimationRef.current = runningAnimation;
runningAnimation.start(({ finished }) => {
runningAnimationRef.current = null;
if (!finished) {
return;
}
Animated.timing(countdownAnimation, {
toValue: 0,
duration: 200,
useNativeDriver: true
}).start(() => {
setShowCountdownText(false);
Animated.parallel([
Animated.timing(buttonAnimation, {
toValue: 0,
duration: 300,
useNativeDriver: true
}),
Animated.timing(taskDetailsAnimation, {
toValue: 0,
duration: 300,
useNativeDriver: true
}),
Animated.timing(cancelButtonAnimation, {
toValue: 0,
duration: 300,
useNativeDriver: true
}),
]).start(() => {
setIsRunning(false);
/* TODO
Implement store and send of ellapsed time value in seconds to DB
for total time spent statistic
*/
})
})
});
}, [cancelButtonAnimation, countdownAnimation, buttonAnimation, containerHeight, duration, isRunning, taskDetailsAnimation, timerAnimation]);
React.useEffect(() => {
if (containerHeight > 0 && !isRunning) {
timerAnimation.setValue(containerHeight);
}
}, [containerHeight, isRunning, timerAnimation])
const cancelButtonOpacity = cancelButtonAnimation;
const cancelButtonTranslateY = cancelButtonAnimation.interpolate({
inputRange: [0, 1],
outputRange: [16, 0],
})
const opacity = buttonAnimation.interpolate({
inputRange: [0, 1],
outputRange: [1, 0]
})
const translateY = buttonAnimation.interpolate({
inputRange: [0, 1],
outputRange: [0, 200]
})
const inactiveTimerNumberOpacity = buttonAnimation.interpolate({
inputRange: [0, 1],
outputRange: [1, 0]
})
const taskDetailsOpacity = taskDetailsAnimation.interpolate({
inputRange: [0, 1],
outputRange: [0, 1]
})
const taskDetailsTranslateY = taskDetailsAnimation.interpolate({
inputRange: [0, 1],
outputRange: [20, 0]
})
const cancelTimer = React.useCallback(() => {
if (!isRunning){
return;
}
if (countdownRef.current) {
clearInterval(countdownRef.current);
countdownRef.current = null;
}
runningAnimationRef.current?.stop();
runningAnimationRef.current = null;
Animated.parallel([
Animated.timing(cancelButtonAnimation, {
toValue: 0,
duration: 180,
useNativeDriver: true
}),
Animated.timing(buttonAnimation, {
toValue: 0,
duration: 220,
useNativeDriver: true
}),
Animated.timing(taskDetailsAnimation, {
toValue: 0,
duration: 220,
useNativeDriver: true
}),
Animated.timing(countdownAnimation, {
toValue: 0,
duration: 180,
useNativeDriver: true
}),
Animated.timing(timerAnimation, {
toValue: containerHeight,
duration: 220,
useNativeDriver: true
}),
]).start(() => {
setShowCountdownText(false);
setTimeRemaining(0);
setIsRunning(false);
});
}, [buttonAnimation, cancelButtonAnimation, containerHeight, countdownAnimation, timerAnimation, isRunning, taskDetailsAnimation,]);
return (
<View style={styles.container}
onLayout={(event) => {
setContainerHeight(event.nativeEvent.layout.height);
}}>
<StatusBar hidden />
<Animated.View
style={[StyleSheet.absoluteFillObject, {
height: containerHeight,
width,
backgroundColor: colors.red,
transform: [{
translateY: timerAnimation
}]
}]}
/>
<Animated.View
style={[
StyleSheet.absoluteFillObject,
{
justifyContent: 'flex-end',
alignItems: 'center',
paddingBottom: 100,
opacity,
transform: [{
translateY
}]
},
]}>
<TouchableOpacity
disabled={isRunning}
onPress={animation}>
<View style={styles.roundButton}>
<Text className='text-text-main'>Start</Text>
</View>
</TouchableOpacity>
</Animated.View>
<Animated.View
pointerEvents={isRunning? 'auto' : 'none'}
style={[
styles.cancelButtonContainer,
{
opacity: cancelButtonOpacity,
transform: [{translateY: cancelButtonTranslateY}],
},
]}>
<TouchableOpacity onPress={cancelTimer} activeOpacity={0.75}>
<View style={styles.cancelButton}>
<Text className='text-text-main'>Cancel</Text>
</View>
</TouchableOpacity>
</Animated.View>
<View
style={{
position: 'absolute',
top: containerHeight / 3,
left: 0,
right: 0,
flex: 1,
}}>
<Animated.FlatList
data={timers}
keyExtractor={item => item.toString()}
horizontal
bounces={false}
onScroll={Animated.event(
[{nativeEvent: {contentOffset: {x: scrollX}}}],
{ useNativeDriver: true}
)}
showsHorizontalScrollIndicator={false}
onMomentumScrollEnd={ev => {
const index = Math.round(ev.nativeEvent.contentOffset.x / ITEM_SIZE)
setSelectedIndex(index);
setDuration(timers[index]);
}}
snapToInterval={ITEM_SIZE}
decelerationRate={"fast"}
style={{flexGrow: 0}}
contentContainerStyle={{
paddingHorizontal: ITEM_SPACING
}}
renderItem={({item, index}) => {
const isSelected = index === selectedIndex;
const timerText = showCountdownText && isSelected ? formatTime(timeRemaining) : item;
const inputRange = [
(index - 1) * ITEM_SIZE,
index * ITEM_SIZE,
(index + 1) * ITEM_SIZE,
]
const normalOpacity = scrollX.interpolate({
inputRange,
outputRange: [.4, 1, .4]
})
const selectedOpacity = scrollX.interpolate({
inputRange,
outputRange: [0, 1, 0],
extrapolate: 'clamp'
})
const opacity = Animated.add(
Animated.multiply(normalOpacity, inactiveTimerNumberOpacity),
Animated.multiply(selectedOpacity, countdownAnimation)
)
const scale = scrollX.interpolate({
inputRange,
outputRange: [.7, 1, .7]
})
return <View style={{width: ITEM_SIZE, justifyContent: 'center', alignItems: 'center'}}>
<Animated.Text style={[showCountdownText && isSelected ? styles.countdownText : styles.text, {
opacity,
transform: [{
scale
}]
}]}>
{timerText}
</Animated.Text>
</View>
}
}
/>
</View>
<Animated.View
pointerEvents="none"
style={[
styles.taskDetails,
{
opacity: taskDetailsOpacity,
transform: [{
translateY: taskDetailsTranslateY
}]
}
]}>
<Text style={styles.taskName}>{placeholderTask.name}</Text>
<Text style={styles.taskDescription}>{placeholderTask.description}</Text>
</Animated.View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.black,
},
roundButton: {
width: 80,
height: 80,
borderRadius: 80,
backgroundColor: colors.red,
alignItems: 'center',
justifyContent: 'center',
},
text: {
fontSize: ITEM_SIZE * 0.8,
fontFamily: 'Menlo',
color: colors.text,
fontWeight: '900',
},
taskDetails: {
position: 'absolute',
top: height * 0.56,
left: 32,
right: 32,
alignItems: 'center',
},
taskName: {
color: colors.text,
fontSize: 24,
fontWeight: '800',
textAlign: 'center',
},
taskDescription: {
color: colors.text,
fontSize: 16,
lineHeight: 22,
marginTop: 10,
textAlign: 'center',
},
countdownText: {
fontSize: ITEM_SIZE * 0.32,
fontFamily: 'Menlo',
color: colors.text,
fontWeight: '900',
},
cancelButtonContainer: {
position: 'absolute',
left: 0,
right: 0,
bottom: 44,
alignItems: 'center',
zIndex: 2,
},
cancelButton: {
minWidth: 112,
height: 44,
borderRadius: 22,
borderWidth: 1,
borderColor: 'rgba(155, 155, 155, 0.35',
backgroundColor: '#beb9a7',
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 22,
},
});