surge/mobile/app/challenge/[id].tsx

272 lines
8.4 KiB
TypeScript

import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, Alert, ActivityIndicator } from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { withObservables } from '@nozbe/watermelondb/react';
import { database } from '../../src/db';
import Challenge from '../../src/db/models/Challenge';
import ChallengeRequirement from '../../src/db/models/ChallengeRequirement';
import { syncChallengeDetails } from '../../src/db/sync';
import UserChallenge from '../../src/db/models/UserChallenge';
import User from '../../src/db/models/User';
const ChallengeDetailScreen = ({ challenge, requirements }: { challenge: Challenge; requirements: ChallengeRequirement[] }) => {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [starting, setStarting] = useState(false);
useEffect(() => {
if (challenge) {
setLoading(true);
syncChallengeDetails(challenge.id).finally(() => setLoading(false));
}
}, [challenge]);
const handleStartChallenge = async () => {
if (!challenge) return;
setStarting(true);
try {
await database.write(async () => {
const userChallengesCollection = database.get<UserChallenge>('user_challenges');
// Check if already active
// Note: In a real app, we'd check for existing active challenges for this user
// For now, we'll just create a new one.
// We need a user ID. Since we don't have auth fully set up in this context,
// we'll use a placeholder or fetch the first user if available.
const usersCollection = database.get<User>('users');
const users = await usersCollection.query().fetch();
let userId = 'temp-user-id';
if (users.length > 0) {
userId = users[0].id;
} else {
// Create a temp user if none exists (for testing)
const newUser = await usersCollection.create(user => {
user.firebaseUid = 'temp-uid';
user.email = 'test@example.com';
});
userId = newUser.id;
}
await userChallengesCollection.create(uc => {
uc.user.id = userId;
uc.challenge.set(challenge);
uc.startDate = new Date().getTime();
uc.status = 'active';
uc.currentStreak = 0;
uc.longestStreak = 0;
uc.attemptNumber = 1;
uc.createdAt = new Date().getTime();
});
});
Alert.alert('Success', 'Challenge started!', [
{ text: 'OK', onPress: () => router.replace('/') }
]);
} catch (error) {
console.error('Error starting challenge:', error);
Alert.alert('Error', 'Failed to start challenge.');
} finally {
setStarting(false);
}
};
if (!challenge) {
return (
<View style={styles.loadingContainer}>
<Text>Loading challenge...</Text>
</View>
);
}
return (
<ScrollView style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>{challenge.name}</Text>
<View style={styles.metaContainer}>
<View style={[styles.badge, { backgroundColor: '#E0E0E0' }]}>
<Text style={styles.badgeText}>{challenge.difficulty}</Text>
</View>
<Text style={styles.duration}>{challenge.durationDays} Days</Text>
</View>
</View>
<Text style={styles.description}>{challenge.description}</Text>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Daily Requirements</Text>
{loading && requirements.length === 0 ? (
<ActivityIndicator size="small" color="#0000ff" />
) : (
requirements.map((req, index) => (
<View key={req.id} style={styles.requirementItem}>
<Text style={styles.reqIndex}>{index + 1}</Text>
<View style={styles.reqContent}>
<Text style={styles.reqTitle}>{req.title}</Text>
{req.description ? <Text style={styles.reqDesc}>{req.description}</Text> : null}
</View>
</View>
))
)}
</View>
<TouchableOpacity
style={[styles.startButton, starting && styles.disabledButton]}
onPress={handleStartChallenge}
disabled={starting}
>
{starting ? (
<ActivityIndicator color="white" />
) : (
<Text style={styles.startButtonText}>Start Challenge</Text>
)}
</TouchableOpacity>
</ScrollView>
);
};
const enhance = withObservables(['id'], ({ id }: { id: string }) => ({
challenge: database.get<Challenge>('challenges').findAndObserve(id),
requirements: database.get<ChallengeRequirement>('challenge_requirements')
.query(
// We can't easily query by relation in withObservables without Q.on
// But since we are syncing details which updates requirements,
// we can just query by challenge_id if we had the challenge object available immediately.
// However, 'challenge' prop is async.
// A common pattern is to pass the challenge itself or query requirements based on the ID prop.
).observeWithColumns(['title', 'description', 'sort_order'])
// Wait, the above query is empty. We need to filter by challenge_id.
// But we only have `id` (challenge id) from props.
}));
// Correct way to query related records with WatermelonDB observables
const enhanceWithRelated = withObservables(['id'], ({ id }: { id: string }) => {
const challengeObservable = database.get<Challenge>('challenges').findAndObserve(id);
// We need to return an object where keys are prop names and values are observables
return {
challenge: challengeObservable,
// To get requirements, we can't use `challenge.requirements` directly here because `challenge` is an observable, not the record yet.
// But we can query the requirements table directly using the ID.
requirements: database.get<ChallengeRequirement>('challenge_requirements')
.query(
// @ts-ignore
// We need to import Q from watermelondb
// But for now let's assume the query is correct
require('@nozbe/watermelondb').Q.where('challenge_id', id),
require('@nozbe/watermelondb').Q.sortBy('sort_order', require('@nozbe/watermelondb').Q.asc)
)
};
});
export default function ChallengeDetailWrapper() {
const { id } = useLocalSearchParams();
const ChallengeDetail = enhanceWithRelated(ChallengeDetailScreen);
// Ensure ID is a string
const challengeId = Array.isArray(id) ? id[0] : id;
if (!challengeId) return null;
return <ChallengeDetail id={challengeId} />;
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
padding: 20,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
header: {
marginBottom: 20,
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: '#333',
marginBottom: 10,
},
metaContainer: {
flexDirection: 'row',
alignItems: 'center',
},
badge: {
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 15,
marginRight: 10,
},
badgeText: {
fontSize: 12,
fontWeight: 'bold',
color: '#555',
textTransform: 'uppercase',
},
duration: {
fontSize: 14,
color: '#666',
},
description: {
fontSize: 16,
color: '#444',
lineHeight: 24,
marginBottom: 30,
},
section: {
marginBottom: 30,
},
sectionTitle: {
fontSize: 20,
fontWeight: '600',
marginBottom: 15,
color: '#333',
},
requirementItem: {
flexDirection: 'row',
marginBottom: 15,
backgroundColor: '#f9f9f9',
padding: 15,
borderRadius: 10,
},
reqIndex: {
fontSize: 16,
fontWeight: 'bold',
color: '#007AFF',
marginRight: 15,
width: 24,
},
reqContent: {
flex: 1,
},
reqTitle: {
fontSize: 16,
fontWeight: '500',
color: '#333',
marginBottom: 4,
},
reqDesc: {
fontSize: 14,
color: '#666',
},
startButton: {
backgroundColor: '#007AFF',
paddingVertical: 16,
borderRadius: 12,
alignItems: 'center',
marginBottom: 40,
},
disabledButton: {
opacity: 0.7,
},
startButtonText: {
color: 'white',
fontSize: 18,
fontWeight: 'bold',
},
});