Skip to content
Merged
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
4 changes: 3 additions & 1 deletion apps/.TODO.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
This file is purely for notes and tracking things I want to implement going forward.
This file is purely for notes and tracking things I want to implement going forward. May not be up to date.


BUGS
- replace when going to the home screen
- Instead of refresshing the session for otdb when we run out of questions we should show some sort of UI to reset the session or allow changing the category selection
- On taking a long time to load for continue, if someone quits the game it should cancel the pending network request and navigaiton
Expand Down
14 changes: 7 additions & 7 deletions apps/cirquiz/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,32 +14,32 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
version: '1.0.0',
orientation: 'portrait',
scheme: 'cirquiz',
icon: './assets/icon.png',
icon: './assets/icon/adaptive-icon.png',
userInterfaceStyle: 'light',
splash: {
image: './assets/splash-icon.png',
image: './assets/icon/splash-icon-light.png',
resizeMode: 'contain',
backgroundColor: '#ffffff',
},
ios: {
supportsTablet: true,
bundleIdentifier: `com.cirquiz.app${s.bundleId}`,
icon: './assets/icon/circlequiz.icon',
infoPlist: {
ITSAppUsesNonExemptEncryption: false,
},
},
android: {
package: `com.cirquiz.app${s.bundleId}`,
adaptiveIcon: {
backgroundColor: '#EBF5FB',
foregroundImage: './assets/android-icon-foreground.png',
backgroundImage: './assets/android-icon-background.png',
monochromeImage: './assets/android-icon-monochrome.png',
backgroundColor: '#FFFFFF',
foregroundImage: './assets/icon/adaptive-icon.png',
monochromeImage: './assets/icon/adaptive-icon.png',
},
predictiveBackGestureEnabled: false,
},
web: {
favicon: './assets/favicon.png',
favicon: './assets/icon/favicon.png',
},
plugins: ['expo-router', 'expo-localization'],
extra: {
Expand Down
16 changes: 13 additions & 3 deletions apps/cirquiz/app/(game)/standings.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router';
import { router, Stack, useLocalSearchParams } from 'expo-router';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ScrollView, StyleSheet, Text, View } from 'react-native';
Expand All @@ -17,6 +17,7 @@ import { ShineButton } from '../../src/components/ShineButton';

export default function StandingsScreen() {
const { t } = useTranslation();
const { popAnimation } = useLocalSearchParams<{ popAnimation?: string }>();
const insets = useSafeAreaInsets();
const ordinalKeys = {
one: 'game.standings.ordinal_ordinal_one',
Expand Down Expand Up @@ -98,6 +99,7 @@ export default function StandingsScreen() {

return (
<GradientScreen showBlobs={false} mode="no-white">
<Stack.Screen options={{ animationTypeForReplace: popAnimation ? 'pop' : 'push' }} />
<ScrollView
ref={scrollRef}
style={styles.scrollView}
Expand Down Expand Up @@ -182,7 +184,7 @@ export default function StandingsScreen() {
if (!showSettings && categories.length === 0) await loadCategories();
setShowSettings((v) => !v);
}}
color={colors.primary}
color={colors.textPrimary}
style={{ alignSelf: 'center', marginTop: spacing.xl, marginBottom: spacing.xs }}
/>
</View>
Expand All @@ -204,10 +206,18 @@ export default function StandingsScreen() {
/>
</View>
)}

<Button
variant="text"
label={t('game.standings.viewSummary')}
onPress={() => router.replace('/(game)/summary')}
color={colors.textPrimary}
style={{ alignSelf: 'center', marginTop: spacing.lg, marginBottom: spacing.sm }}
/>
</ScrollView>

<View
style={[styles.stickyBottom, { paddingBottom: insets.bottom + spacing.md }]}
style={[styles.stickyBottom, { paddingBottom: insets.bottom + spacing.xs }]}
onLayout={(e) => setStickyBottomHeight(e.nativeEvent.layout.height)}
>
<LinearGradient
Expand Down
269 changes: 269 additions & 0 deletions apps/cirquiz/app/(game)/summary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FlatList, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Animated, {
Extrapolation,
interpolate,
useAnimatedScrollHandler,
useAnimatedStyle,
useSharedValue,
} from 'react-native-reanimated';
import { AvatarIcon } from '../../src/components/AvatarIcon';
import { Button } from '../../src/components/Button';
import { GameHeader } from '../../src/components/GameHeader';
import { GradientScreen } from '../../src/components/GradientScreen';
import { ShineButton } from '../../src/components/ShineButton';
import { useQuitGame } from '../../src/hooks/useQuitGame';
import { useGameStore } from '../../src/state/gameStore';
import { Turn } from '../../src/state/types';
import { Question } from '../../src/providers/types';
import { colors, fontSize, fontWeight, radius, spacing } from '../../src/theme';

type RoundHeaderItem = {
type: 'round-header';
key: string;
roundIndex: number;
};

type QuestionItem = {
type: 'question';
key: string;
question: Question;
turns: Turn[];
questionIndex: number;
};

type ListItem = RoundHeaderItem | QuestionItem;

const AnimatedFlatList = Animated.createAnimatedComponent(FlatList<ListItem>);

export default function SummaryScreen() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const game = useGameStore((s) => s.game);
const startNextRound = useGameStore((s) => s.startNextRound);
const quitGame = useGameStore((s) => s.quitGame);
const isLoading = useGameStore((s) => s.isLoading);
const handleQuit = useQuitGame();
const [stickyBottomHeight, setStickyBottomHeight] = useState(0);

const scrollY = useSharedValue(0);
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
scrollY.value = event.contentOffset.y;
},
});
const overlayStyle = useAnimatedStyle(() => ({
opacity: interpolate(scrollY.value, [0, spacing.xl + spacing.sm], [0, 1], Extrapolation.CLAMP),
}));

const items = useMemo<ListItem[]>(() => {
if (!game) return [];
const completedRounds = game.rounds.filter((r) => r.state === 'completed');
const multipleRounds = completedRounds.length > 1;
return completedRounds
.flatMap((round, roundIndex) => {
const header: RoundHeaderItem | null = multipleRounds
? { type: 'round-header', key: `header-${round.id}`, roundIndex }
: null;
const questions: QuestionItem[] = round.questions
.map((question, questionIndex) => ({
type: 'question' as const,
key: `${round.id}-${question.id}`,
question,
turns: round.turns.filter((tu) => tu.questionId === question.id),
questionIndex,
}))
.reverse();
return header ? [...questions, header] : questions;
})
.reverse();
}, [game]);

if (!game) return null;

const handleEndSession = () => {
quitGame();
router.replace('/');
};

return (
<GradientScreen showBlobs={false} mode="no-white">
<GameHeader
variant="transparent"
onBack={() =>
router.replace({ pathname: '/(game)/standings', params: { popAnimation: '1' } })
}
onQuit={handleQuit}
/>
<View style={styles.scrollContainer}>
<AnimatedFlatList
data={items}
keyExtractor={(item) => item.key}
style={styles.list}
contentContainerStyle={{
paddingHorizontal: spacing.xl,
paddingBottom: stickyBottomHeight + spacing.xl,
}}
onScroll={scrollHandler}
scrollEventThrottle={16}
ListHeaderComponent={<Text style={styles.title}>{t('game.summary.title')}</Text>}
renderItem={({ item }) => {
if (item.type === 'round-header') {
return (
<Text style={styles.roundHeader}>
{t('game.summary.roundLabel', { n: item.roundIndex + 1 })}
</Text>
);
}

const { question, turns, questionIndex } = item;
return (
<View style={styles.questionCard}>
<Text style={styles.questionIndex}>
{t('game.summary.questionLabel', { n: questionIndex + 1 })}
</Text>
<Text style={styles.questionText}>{question.text}</Text>
<Text style={styles.correctAnswer}>
{t('game.summary.correctAnswer', { answer: question.correctAnswer })}
</Text>
<View style={styles.playerAnswers}>
{game.players.map((player) => {
const turn = turns.find((tu) => tu.playerId === player.id);
if (!turn) return null;
return (
<View
key={player.id}
style={[styles.playerRow, { borderLeftColor: player.color }]}
>
<AvatarIcon avatarKey={player.avatar} size={28} style={styles.avatar} />
<View style={styles.playerInfo}>
<Text style={styles.playerName}>{player.name}</Text>
<Text style={styles.playerAnswer}>{turn.selectedAnswer}</Text>
</View>
<Ionicons
name={turn.isCorrect ? 'checkmark-circle' : 'close-circle'}
size={22}
color={turn.isCorrect ? colors.success : colors.error}
/>
</View>
);
})}
</View>
</View>
);
}}
/>
<Animated.View style={[styles.fadeOverlay, overlayStyle]} pointerEvents="none">
<LinearGradient
colors={[colors.primaryFaint, 'rgba(255,255,255,0)']}
style={StyleSheet.absoluteFill}
/>
</Animated.View>
</View>

<View
style={[styles.stickyBottom, { paddingBottom: insets.bottom + spacing.xs }]}
onLayout={(e) => setStickyBottomHeight(e.nativeEvent.layout.height)}
>
<LinearGradient
style={StyleSheet.absoluteFill}
colors={['rgba(243,238,255,0)', colors.difficultyFaint]}
pointerEvents="none"
locations={[0, 0.3]}
/>
<ShineButton
label={t('game.summary.playAnotherRound')}
color={colors.success}
loading={isLoading}
onPress={() => {
startNextRound();
}}
style={styles.roundButton}
/>
<Button
variant="text"
label={t('game.summary.endSession')}
color={colors.error}
onPress={handleEndSession}
haptic="light"
/>
</View>
</GradientScreen>
);
}

const styles = StyleSheet.create({
scrollContainer: { flex: 1 },
list: { flex: 1 },
fadeOverlay: {
position: 'absolute',
top: 0,
alignSelf: 'center',
width: '100%',
height: 40,
overflow: 'hidden',
borderBottomLeftRadius: 20,
borderBottomRightRadius: 20,
},
title: {
fontSize: fontSize['3xl'],
fontWeight: fontWeight.bold,
marginBottom: spacing.xl,
textAlign: 'center',
},
roundHeader: {
fontSize: fontSize.sm,
fontWeight: fontWeight.semibold,
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 1,
marginTop: spacing.lg,
marginBottom: spacing.sm,
},
questionCard: {
backgroundColor: colors.white,
borderRadius: radius.md,
padding: spacing.lg,
marginBottom: spacing.md,
},
questionIndex: { fontSize: fontSize.xs, color: colors.textMuted, marginBottom: spacing.sm },
questionText: {
fontSize: fontSize.base,
fontWeight: fontWeight.semibold,
color: colors.text,
marginBottom: spacing.sm,
},
correctAnswer: {
fontSize: fontSize.sm,
color: colors.success,
fontWeight: fontWeight.semibold,
marginBottom: spacing.md,
},
playerAnswers: { gap: spacing.sm },
playerRow: {
flexDirection: 'row',
alignItems: 'center',
padding: spacing.md,
borderRadius: radius.sm,
borderLeftWidth: 3,
backgroundColor: colors.surface,
},
avatar: { marginRight: spacing.sm, flexShrink: 0 },
playerInfo: { flex: 1 },
playerName: { fontSize: fontSize.sm, fontWeight: fontWeight.semibold, color: colors.text },
playerAnswer: { fontSize: fontSize.sm, color: colors.textSecondary, marginTop: spacing.xs },
stickyBottom: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
paddingHorizontal: spacing.xl,
paddingTop: spacing['2xl'],
},
roundButton: { marginBottom: spacing.md },
});
4 changes: 2 additions & 2 deletions apps/cirquiz/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { IconButton } from '../src/components/IconButton';
import { ShineButton } from '../src/components/ShineButton';
import { useGameStore } from '../src/state/gameStore';
import { colors, fontSize, spacing } from '../src/theme';
import CirclequizLogo from '../assets/circlequiz.svg';
import CirclequizLogo from '../assets/circlequiz-wordmark.svg';

const DOT_COLORS = [
colors.playerPalette.blue,
Expand Down Expand Up @@ -142,7 +142,7 @@ export default function HomeScreen() {
<GradientScreen>
<View style={styles.loadingContainer}>
<View style={styles.logoWrapper}>
<CirclequizLogo width={300} height={84} />
<CirclequizLogo />
</View>
<View style={styles.dotsRow}>
{DOT_COLORS.map((color, i) => (
Expand Down
2 changes: 1 addition & 1 deletion apps/cirquiz/app/setup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ export default function SetupScreen() {
variant="text"
label={`+ ${t('setup.addPlayer')}`}
onPress={addPlayer}
color={colors.primary}
color={colors.textPrimary}
/>
)}

Expand Down
Binary file removed apps/cirquiz/assets/android-icon-background.png
Binary file not shown.
Binary file removed apps/cirquiz/assets/android-icon-foreground.png
Binary file not shown.
Binary file removed apps/cirquiz/assets/android-icon-monochrome.png
Binary file not shown.
Loading
Loading