You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1392 lines
39 KiB
Markdown
1392 lines
39 KiB
Markdown
# Know Foolery - Cross-Platform UI Strategy with Gluestack UI
|
|
|
|
## Overview
|
|
|
|
Gluestack UI provides a comprehensive solution for building consistent, performant user interfaces across web (React) and mobile (React Native) platforms. This document outlines the strategy for implementing Know Foolery's cross-platform UI using Gluestack UI with NativeWind/Tailwind CSS.
|
|
|
|
## Gluestack UI Architecture
|
|
|
|
### Cross-Platform Component Architecture
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────────────┐
|
|
│ Shared UI Layer │
|
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
|
│ │ Design │ │ Component │ │ Shared │ │ Theme │ │
|
|
│ │ Tokens │ │ Library │ │ Logic │ │ System │ │
|
|
│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │
|
|
└─────────────────────────────────────────────────────────────────────────┘
|
|
│
|
|
Platform Adaptation
|
|
│
|
|
┌─────────────────────────────────────────────────────────────────────────┐
|
|
│ Platform Renderers │
|
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
|
│ │ Web DOM │ │ iOS │ │ Android │ │ Desktop │ │
|
|
│ │ (React) │ │ (RN iOS) │ │ (RN Android)│ │ (Wails) │ │
|
|
│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │
|
|
└─────────────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
## Project Structure for Cross-Platform UI
|
|
|
|
### Recommended Package Organization
|
|
|
|
```
|
|
frontend/
|
|
├── packages/
|
|
│ ├── ui-components/ # Shared Gluestack UI components
|
|
│ │ ├── src/
|
|
│ │ │ ├── components/
|
|
│ │ │ │ ├── GameCard/
|
|
│ │ │ │ │ ├── GameCard.tsx
|
|
│ │ │ │ │ ├── GameCard.stories.tsx
|
|
│ │ │ │ │ ├── GameCard.test.tsx
|
|
│ │ │ │ │ └── index.ts
|
|
│ │ │ │ ├── Leaderboard/
|
|
│ │ │ │ ├── Timer/
|
|
│ │ │ │ ├── ScoreDisplay/
|
|
│ │ │ │ ├── AdminPanel/
|
|
│ │ │ │ └── index.ts
|
|
│ │ │ ├── theme/
|
|
│ │ │ │ ├── tokens.ts # Design tokens
|
|
│ │ │ │ ├── colors.ts
|
|
│ │ │ │ ├── typography.ts
|
|
│ │ │ │ ├── spacing.ts
|
|
│ │ │ │ └── index.ts
|
|
│ │ │ └── index.ts
|
|
│ │ ├── package.json
|
|
│ │ └── gluestack-ui.config.ts
|
|
│ ├── shared-logic/ # Business logic
|
|
│ │ ├── src/
|
|
│ │ │ ├── hooks/
|
|
│ │ │ ├── services/
|
|
│ │ │ ├── utils/
|
|
│ │ │ └── types/
|
|
│ │ └── package.json
|
|
│ └── shared-types/ # TypeScript types
|
|
│ ├── src/
|
|
│ │ ├── api.ts
|
|
│ │ ├── game.ts
|
|
│ │ └── index.ts
|
|
│ └── package.json
|
|
├── apps/
|
|
│ ├── web/ # React web app
|
|
│ │ ├── src/
|
|
│ │ │ ├── pages/
|
|
│ │ │ ├── components/ # Web-specific components
|
|
│ │ │ └── main.tsx
|
|
│ │ ├── vite.config.ts
|
|
│ │ └── package.json
|
|
│ ├── mobile/ # React Native app
|
|
│ │ ├── src/
|
|
│ │ │ ├── screens/
|
|
│ │ │ ├── components/ # Mobile-specific components
|
|
│ │ │ └── App.tsx
|
|
│ │ ├── metro.config.js
|
|
│ │ └── package.json
|
|
│ └── desktop/ # Wails desktop app
|
|
│ ├── frontend/
|
|
│ ├── app/
|
|
│ └── wails.json
|
|
└── storybook/ # Component documentation
|
|
├── stories/
|
|
└── .storybook/
|
|
```
|
|
|
|
## Theme System Implementation
|
|
|
|
### Design Tokens with Quiz Game Branding
|
|
|
|
```typescript
|
|
// packages/ui-components/src/theme/tokens.ts
|
|
export const designTokens = {
|
|
colors: {
|
|
// Brand colors for Know Foolery
|
|
brand: {
|
|
50: '#eff6ff', // Very light blue
|
|
100: '#dbeafe', // Light blue
|
|
200: '#bfdbfe', // Lighter blue
|
|
300: '#93c5fd', // Light blue
|
|
400: '#60a5fa', // Medium blue
|
|
500: '#3b82f6', // Primary blue (main brand)
|
|
600: '#2563eb', // Darker blue
|
|
700: '#1d4ed8', // Dark blue
|
|
800: '#1e40af', // Very dark blue
|
|
900: '#1e3a8a', // Darkest blue
|
|
},
|
|
|
|
// Semantic colors for game states
|
|
success: {
|
|
50: '#f0fdf4', // Correct answer background
|
|
500: '#22c55e', // Correct answer green
|
|
600: '#16a34a', // Darker correct green
|
|
},
|
|
|
|
error: {
|
|
50: '#fef2f2', // Wrong answer background
|
|
500: '#ef4444', // Wrong answer red
|
|
600: '#dc2626', // Darker wrong red
|
|
},
|
|
|
|
warning: {
|
|
50: '#fffbeb', // Hint background
|
|
500: '#f59e0b', // Hint orange/yellow
|
|
600: '#d97706', // Darker hint color
|
|
},
|
|
|
|
// Neutral colors
|
|
gray: {
|
|
50: '#f9fafb',
|
|
100: '#f3f4f6',
|
|
200: '#e5e7eb',
|
|
300: '#d1d5db',
|
|
400: '#9ca3af',
|
|
500: '#6b7280',
|
|
600: '#4b5563',
|
|
700: '#374151',
|
|
800: '#1f2937',
|
|
900: '#111827',
|
|
},
|
|
},
|
|
|
|
spacing: {
|
|
0: '0px',
|
|
1: '4px',
|
|
2: '8px',
|
|
3: '12px',
|
|
4: '16px',
|
|
5: '20px',
|
|
6: '24px',
|
|
8: '32px',
|
|
10: '40px',
|
|
12: '48px',
|
|
16: '64px',
|
|
20: '80px',
|
|
24: '96px',
|
|
},
|
|
|
|
typography: {
|
|
fontSizes: {
|
|
xs: '12px',
|
|
sm: '14px',
|
|
md: '16px',
|
|
lg: '18px',
|
|
xl: '20px',
|
|
'2xl': '24px',
|
|
'3xl': '30px',
|
|
'4xl': '36px',
|
|
},
|
|
|
|
fontWeights: {
|
|
normal: '400',
|
|
medium: '500',
|
|
semibold: '600',
|
|
bold: '700',
|
|
extrabold: '800',
|
|
},
|
|
|
|
lineHeights: {
|
|
tight: '1.25',
|
|
normal: '1.5',
|
|
relaxed: '1.75',
|
|
},
|
|
|
|
letterSpacings: {
|
|
tight: '-0.025em',
|
|
normal: '0em',
|
|
wide: '0.025em',
|
|
},
|
|
},
|
|
|
|
borderRadius: {
|
|
none: '0px',
|
|
sm: '4px',
|
|
md: '8px',
|
|
lg: '12px',
|
|
xl: '16px',
|
|
full: '9999px',
|
|
},
|
|
|
|
shadows: {
|
|
sm: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
|
|
md: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
|
lg: '0 10px 15px -3px rgb(0 0 0 / 0.1)',
|
|
xl: '0 20px 25px -5px rgb(0 0 0 / 0.1)',
|
|
},
|
|
}
|
|
|
|
// Custom semantic tokens for game elements
|
|
export const gameTokens = {
|
|
// Question difficulty indicators
|
|
difficulty: {
|
|
easy: designTokens.colors.success[500],
|
|
medium: designTokens.colors.warning[500],
|
|
hard: designTokens.colors.error[500],
|
|
},
|
|
|
|
// Timer states
|
|
timer: {
|
|
normal: designTokens.colors.gray[600],
|
|
warning: designTokens.colors.warning[500], // 5 minutes left
|
|
critical: designTokens.colors.error[500], // 1 minute left
|
|
},
|
|
|
|
// Score indicators
|
|
score: {
|
|
excellent: designTokens.colors.success[500], // 80%+ correct
|
|
good: designTokens.colors.brand[500], // 60-80% correct
|
|
average: designTokens.colors.warning[500], // 40-60% correct
|
|
poor: designTokens.colors.error[500], // <40% correct
|
|
},
|
|
|
|
// Leaderboard positions
|
|
leaderboard: {
|
|
first: '#ffd700', // Gold
|
|
second: '#c0c0c0', // Silver
|
|
third: '#cd7f32', // Bronze
|
|
other: designTokens.colors.gray[600],
|
|
},
|
|
}
|
|
|
|
// Export combined theme
|
|
export const knowFooleryTheme = {
|
|
...designTokens,
|
|
game: gameTokens,
|
|
}
|
|
```
|
|
|
|
### Gluestack UI Configuration
|
|
|
|
```typescript
|
|
// packages/ui-components/gluestack-ui.config.ts
|
|
import { createConfig } from '@gluestack-ui/themed'
|
|
import { knowFooleryTheme } from './src/theme'
|
|
|
|
export const config = createConfig({
|
|
aliases: {
|
|
bg: 'backgroundColor',
|
|
p: 'padding',
|
|
m: 'margin',
|
|
w: 'width',
|
|
h: 'height',
|
|
},
|
|
|
|
tokens: {
|
|
colors: knowFooleryTheme.colors,
|
|
space: knowFooleryTheme.spacing,
|
|
fontSizes: knowFooleryTheme.typography.fontSizes,
|
|
fontWeights: knowFooleryTheme.typography.fontWeights,
|
|
lineHeights: knowFooleryTheme.typography.lineHeights,
|
|
letterSpacings: knowFooleryTheme.typography.letterSpacings,
|
|
radii: knowFooleryTheme.borderRadius,
|
|
shadows: knowFooleryTheme.shadows,
|
|
},
|
|
|
|
globalStyle: {
|
|
variants: {
|
|
hardShadow: {
|
|
shadowColor: '$backgroundLight800',
|
|
shadowOffset: {
|
|
width: 2,
|
|
height: 2,
|
|
},
|
|
shadowOpacity: 0.6,
|
|
shadowRadius: 8,
|
|
elevation: 10,
|
|
},
|
|
},
|
|
},
|
|
|
|
// Custom component variants
|
|
components: {
|
|
Button: {
|
|
theme: {
|
|
variants: {
|
|
solid: {
|
|
'bg': '$brand500',
|
|
'_text': {
|
|
'color': '$white',
|
|
'fontWeight': '$semibold',
|
|
},
|
|
'_hover': {
|
|
'bg': '$brand600',
|
|
},
|
|
'_pressed': {
|
|
'bg': '$brand700',
|
|
},
|
|
},
|
|
|
|
outline: {
|
|
'borderWidth': 2,
|
|
'borderColor': '$brand500',
|
|
'_text': {
|
|
'color': '$brand500',
|
|
'fontWeight': '$semibold',
|
|
},
|
|
'_hover': {
|
|
'bg': '$brand50',
|
|
},
|
|
},
|
|
|
|
ghost: {
|
|
'_text': {
|
|
'color': '$brand500',
|
|
},
|
|
'_hover': {
|
|
'bg': '$brand50',
|
|
},
|
|
},
|
|
},
|
|
|
|
sizes: {
|
|
sm: {
|
|
'px': '$3',
|
|
'py': '$2',
|
|
'_text': {
|
|
'fontSize': '$sm',
|
|
},
|
|
},
|
|
md: {
|
|
'px': '$4',
|
|
'py': '$3',
|
|
'_text': {
|
|
'fontSize': '$md',
|
|
},
|
|
},
|
|
lg: {
|
|
'px': '$6',
|
|
'py': '$4',
|
|
'_text': {
|
|
'fontSize': '$lg',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
|
|
Card: {
|
|
theme: {
|
|
'bg': '$white',
|
|
'rounded': '$lg',
|
|
'shadowColor': '$backgroundLight800',
|
|
'shadowOffset': {
|
|
width: 0,
|
|
height: 2,
|
|
},
|
|
'shadowOpacity': 0.1,
|
|
'shadowRadius': 8,
|
|
'elevation': 3,
|
|
|
|
variants: {
|
|
elevated: {
|
|
'shadowOpacity': 0.15,
|
|
'elevation': 6,
|
|
},
|
|
|
|
outlined: {
|
|
'borderWidth': 1,
|
|
'borderColor': '$gray200',
|
|
'shadowOpacity': 0,
|
|
'elevation': 0,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
|
|
Badge: {
|
|
theme: {
|
|
'rounded': '$full',
|
|
'px': '$3',
|
|
'py': '$1',
|
|
|
|
variants: {
|
|
solid: {
|
|
'bg': '$brand500',
|
|
'_text': {
|
|
'color': '$white',
|
|
'fontSize': '$sm',
|
|
'fontWeight': '$semibold',
|
|
},
|
|
},
|
|
|
|
outline: {
|
|
'borderWidth': 1,
|
|
'borderColor': '$brand500',
|
|
'_text': {
|
|
'color': '$brand500',
|
|
'fontSize': '$sm',
|
|
'fontWeight': '$semibold',
|
|
},
|
|
},
|
|
|
|
subtle: {
|
|
'bg': '$brand50',
|
|
'_text': {
|
|
'color': '$brand700',
|
|
'fontSize': '$sm',
|
|
'fontWeight': '$semibold',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
export type Config = typeof config
|
|
```
|
|
|
|
## Core Game Components
|
|
|
|
### GameCard Component - The Heart of the Quiz
|
|
|
|
```typescript
|
|
// packages/ui-components/src/components/GameCard/GameCard.tsx
|
|
import React, { useState, useEffect, useCallback } from 'react'
|
|
import {
|
|
Card,
|
|
VStack,
|
|
HStack,
|
|
Text,
|
|
Input,
|
|
InputField,
|
|
Button,
|
|
ButtonText,
|
|
Badge,
|
|
Progress,
|
|
ProgressFilledTrack,
|
|
Box,
|
|
Pressable,
|
|
} from '@gluestack-ui/themed'
|
|
import { knowFooleryTheme } from '../../theme'
|
|
|
|
export interface GameCardProps {
|
|
// Game data
|
|
question: string
|
|
theme: string
|
|
difficulty?: 'easy' | 'medium' | 'hard'
|
|
|
|
// Game state
|
|
timeRemaining: number
|
|
attemptsLeft: number
|
|
currentScore: number
|
|
hintUsed: boolean
|
|
|
|
// Interaction handlers
|
|
onSubmitAnswer: (answer: string) => Promise<void>
|
|
onRequestHint: () => Promise<void>
|
|
onTimeExpire?: () => void
|
|
|
|
// UI state
|
|
isLoading?: boolean
|
|
hint?: string
|
|
showHint?: boolean
|
|
}
|
|
|
|
export const GameCard: React.FC<GameCardProps> = ({
|
|
question,
|
|
theme,
|
|
difficulty = 'medium',
|
|
timeRemaining,
|
|
attemptsLeft,
|
|
currentScore,
|
|
hintUsed,
|
|
onSubmitAnswer,
|
|
onRequestHint,
|
|
onTimeExpire,
|
|
isLoading = false,
|
|
hint,
|
|
showHint = false,
|
|
}) => {
|
|
const [answer, setAnswer] = useState<string>('')
|
|
const [isSubmitting, setIsSubmitting] = useState<boolean>(false)
|
|
|
|
// Timer formatting
|
|
const formatTime = useCallback((seconds: number): string => {
|
|
const minutes = Math.floor(seconds / 60)
|
|
const remainingSeconds = seconds % 60
|
|
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
|
|
}, [])
|
|
|
|
// Handle answer submission
|
|
const handleSubmit = useCallback(async () => {
|
|
if (!answer.trim() || isSubmitting || isLoading) return
|
|
|
|
setIsSubmitting(true)
|
|
try {
|
|
await onSubmitAnswer(answer.trim())
|
|
setAnswer('') // Clear input after successful submission
|
|
} catch (error) {
|
|
// Error handling managed by parent component
|
|
console.error('Failed to submit answer:', error)
|
|
} finally {
|
|
setIsSubmitting(false)
|
|
}
|
|
}, [answer, isSubmitting, isLoading, onSubmitAnswer])
|
|
|
|
// Handle hint request
|
|
const handleHintRequest = useCallback(async () => {
|
|
if (isLoading || isSubmitting || hintUsed) return
|
|
|
|
try {
|
|
await onRequestHint()
|
|
} catch (error) {
|
|
console.error('Failed to request hint:', error)
|
|
}
|
|
}, [isLoading, isSubmitting, hintUsed, onRequestHint])
|
|
|
|
// Time expiration effect
|
|
useEffect(() => {
|
|
if (timeRemaining === 0 && onTimeExpire) {
|
|
onTimeExpire()
|
|
}
|
|
}, [timeRemaining, onTimeExpire])
|
|
|
|
// Computed values
|
|
const progressValue = ((30 * 60 - timeRemaining) / (30 * 60)) * 100
|
|
const isTimeWarning = timeRemaining <= 300 // 5 minutes
|
|
const isTimeCritical = timeRemaining <= 60 // 1 minute
|
|
const difficultyColor = knowFooleryTheme.game.difficulty[difficulty]
|
|
const timerColor = isTimeCritical
|
|
? knowFooleryTheme.game.timer.critical
|
|
: isTimeWarning
|
|
? knowFooleryTheme.game.timer.warning
|
|
: knowFooleryTheme.game.timer.normal
|
|
|
|
return (
|
|
<Card size="lg" variant="elevated" m="$4" testID="game-card">
|
|
<VStack space="md" p="$6">
|
|
{/* Header with theme, difficulty, and timer */}
|
|
<HStack justifyContent="space-between" alignItems="center">
|
|
<HStack space="sm" alignItems="center">
|
|
<Badge variant="solid" bg={difficultyColor} testID="theme-badge">
|
|
<Text color="$white" fontSize="$sm" fontWeight="$semibold">
|
|
{theme}
|
|
</Text>
|
|
</Badge>
|
|
|
|
{difficulty && (
|
|
<Badge variant="outline" borderColor={difficultyColor} testID="difficulty-badge">
|
|
<Text color={difficultyColor} fontSize="$xs" fontWeight="$medium">
|
|
{difficulty.toUpperCase()}
|
|
</Text>
|
|
</Badge>
|
|
)}
|
|
</HStack>
|
|
|
|
<HStack space="sm" alignItems="center">
|
|
<Text
|
|
fontSize="$sm"
|
|
color={timerColor}
|
|
fontWeight="$semibold"
|
|
testID="timer-display"
|
|
>
|
|
⏱️ {formatTime(timeRemaining)}
|
|
</Text>
|
|
</HStack>
|
|
</HStack>
|
|
|
|
{/* Session progress indicator */}
|
|
<Progress value={progressValue} size="sm" testID="session-progress">
|
|
<ProgressFilledTrack bg="$brand500" />
|
|
</Progress>
|
|
|
|
{/* Question display */}
|
|
<Box>
|
|
<Text
|
|
fontSize="$xl"
|
|
fontWeight="$semibold"
|
|
lineHeight="$relaxed"
|
|
color="$gray900"
|
|
testID="question-text"
|
|
>
|
|
{question}
|
|
</Text>
|
|
</Box>
|
|
|
|
{/* Hint display */}
|
|
{showHint && hint && (
|
|
<Box
|
|
bg="$warning50"
|
|
p="$4"
|
|
rounded="$md"
|
|
borderLeftWidth={4}
|
|
borderLeftColor="$warning500"
|
|
testID="hint-display"
|
|
>
|
|
<HStack space="sm" alignItems="flex-start">
|
|
<Text fontSize="$lg">💡</Text>
|
|
<Text fontSize="$md" color="$gray700" flex={1}>
|
|
{hint}
|
|
</Text>
|
|
</HStack>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Answer input */}
|
|
<Input
|
|
size="lg"
|
|
isDisabled={isLoading || isSubmitting}
|
|
testID="answer-input"
|
|
>
|
|
<InputField
|
|
placeholder="Enter your answer..."
|
|
value={answer}
|
|
onChangeText={setAnswer}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
onSubmitEditing={handleSubmit}
|
|
returnKeyType="send"
|
|
fontSize="$md"
|
|
/>
|
|
</Input>
|
|
|
|
{/* Game statistics */}
|
|
<HStack justifyContent="space-between" alignItems="center">
|
|
<Text fontSize="$sm" color="$gray600" testID="attempts-counter">
|
|
<Text fontWeight="$semibold">{attemptsLeft}</Text> attempts left
|
|
</Text>
|
|
<Text fontSize="$sm" color="$gray600" testID="score-display">
|
|
Score: <Text fontWeight="$semibold" color="$brand600">{currentScore}</Text> points
|
|
</Text>
|
|
</HStack>
|
|
|
|
{/* Action buttons */}
|
|
<HStack space="sm">
|
|
<Button
|
|
size="lg"
|
|
variant="solid"
|
|
flex={1}
|
|
onPress={handleSubmit}
|
|
isDisabled={!answer.trim() || isLoading || isSubmitting}
|
|
testID="submit-button"
|
|
>
|
|
<ButtonText>
|
|
{isSubmitting ? 'Submitting...' : 'Submit Answer'}
|
|
</ButtonText>
|
|
</Button>
|
|
|
|
<Button
|
|
size="lg"
|
|
variant="outline"
|
|
onPress={handleHintRequest}
|
|
isDisabled={isLoading || isSubmitting || hintUsed}
|
|
testID="hint-button"
|
|
px="$4"
|
|
>
|
|
<ButtonText>
|
|
{hintUsed ? '💡 Used' : '💡 Hint'}
|
|
</ButtonText>
|
|
</Button>
|
|
</HStack>
|
|
|
|
{/* Hint usage warning */}
|
|
{!hintUsed && (
|
|
<Text fontSize="$xs" color="$gray500" textAlign="center">
|
|
Using a hint reduces your score to 1 point for this question
|
|
</Text>
|
|
)}
|
|
</VStack>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
export default GameCard
|
|
```
|
|
|
|
### Leaderboard Component
|
|
|
|
```typescript
|
|
// packages/ui-components/src/components/Leaderboard/Leaderboard.tsx
|
|
import React from 'react'
|
|
import {
|
|
Card,
|
|
VStack,
|
|
HStack,
|
|
Text,
|
|
Box,
|
|
Badge,
|
|
Divider,
|
|
ScrollView,
|
|
} from '@gluestack-ui/themed'
|
|
import { knowFooleryTheme } from '../../theme'
|
|
|
|
export interface LeaderboardEntry {
|
|
position: number
|
|
playerName: string
|
|
score: number
|
|
questionsAnswered: number
|
|
successRate: number
|
|
completedAt: string
|
|
isCurrentUser?: boolean
|
|
}
|
|
|
|
export interface LeaderboardProps {
|
|
entries: LeaderboardEntry[]
|
|
currentUserPosition?: number
|
|
isLoading?: boolean
|
|
title?: string
|
|
}
|
|
|
|
export const Leaderboard: React.FC<LeaderboardProps> = ({
|
|
entries,
|
|
currentUserPosition,
|
|
isLoading = false,
|
|
title = "🏆 Leaderboard",
|
|
}) => {
|
|
const getPositionColor = (position: number) => {
|
|
switch (position) {
|
|
case 1: return knowFooleryTheme.game.leaderboard.first
|
|
case 2: return knowFooleryTheme.game.leaderboard.second
|
|
case 3: return knowFooleryTheme.game.leaderboard.third
|
|
default: return knowFooleryTheme.game.leaderboard.other
|
|
}
|
|
}
|
|
|
|
const getPositionIcon = (position: number) => {
|
|
switch (position) {
|
|
case 1: return '🥇'
|
|
case 2: return '🥈'
|
|
case 3: return '🥉'
|
|
default: return `#${position}`
|
|
}
|
|
}
|
|
|
|
const formatSuccessRate = (rate: number) => `${Math.round(rate)}%`
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<Card size="lg" m="$4" testID="leaderboard-loading">
|
|
<VStack space="md" p="$6">
|
|
<Text fontSize="$xl" fontWeight="$bold" textAlign="center">
|
|
{title}
|
|
</Text>
|
|
<Text textAlign="center" color="$gray500">
|
|
Loading leaderboard...
|
|
</Text>
|
|
</VStack>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Card size="lg" m="$4" testID="leaderboard">
|
|
<VStack space="md" p="$6">
|
|
{/* Header */}
|
|
<HStack justifyContent="space-between" alignItems="center">
|
|
<Text fontSize="$xl" fontWeight="$bold" testID="leaderboard-title">
|
|
{title}
|
|
</Text>
|
|
{currentUserPosition && (
|
|
<Badge variant="subtle" testID="user-position">
|
|
<Text fontSize="$sm" fontWeight="$semibold">
|
|
Your Rank: #{currentUserPosition}
|
|
</Text>
|
|
</Badge>
|
|
)}
|
|
</HStack>
|
|
|
|
<Divider />
|
|
|
|
{/* Leaderboard entries */}
|
|
<ScrollView maxHeight={400} showsVerticalScrollIndicator={false}>
|
|
<VStack space="sm">
|
|
{entries.map((entry, index) => (
|
|
<LeaderboardRow
|
|
key={`${entry.position}-${entry.playerName}`}
|
|
entry={entry}
|
|
positionColor={getPositionColor(entry.position)}
|
|
positionIcon={getPositionIcon(entry.position)}
|
|
/>
|
|
))}
|
|
|
|
{entries.length === 0 && (
|
|
<Box py="$8">
|
|
<Text textAlign="center" color="$gray500" fontSize="$md">
|
|
No scores yet. Be the first to play!
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
</VStack>
|
|
</ScrollView>
|
|
</VStack>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
interface LeaderboardRowProps {
|
|
entry: LeaderboardEntry
|
|
positionColor: string
|
|
positionIcon: string
|
|
}
|
|
|
|
const LeaderboardRow: React.FC<LeaderboardRowProps> = ({
|
|
entry,
|
|
positionColor,
|
|
positionIcon
|
|
}) => {
|
|
return (
|
|
<Box
|
|
bg={entry.isCurrentUser ? '$brand50' : '$white'}
|
|
p="$3"
|
|
rounded="$md"
|
|
borderWidth={entry.isCurrentUser ? 2 : 1}
|
|
borderColor={entry.isCurrentUser ? '$brand200' : '$gray100'}
|
|
testID={`leaderboard-row-${entry.position}`}
|
|
>
|
|
<HStack space="md" alignItems="center">
|
|
{/* Position */}
|
|
<Box minWidth={40}>
|
|
<Text
|
|
fontSize="$lg"
|
|
fontWeight="$bold"
|
|
color={positionColor}
|
|
textAlign="center"
|
|
>
|
|
{positionIcon}
|
|
</Text>
|
|
</Box>
|
|
|
|
{/* Player info */}
|
|
<VStack flex={1} space="xs">
|
|
<HStack justifyContent="space-between" alignItems="center">
|
|
<Text
|
|
fontSize="$md"
|
|
fontWeight="$semibold"
|
|
color={entry.isCurrentUser ? '$brand700' : '$gray900'}
|
|
numberOfLines={1}
|
|
ellipsizeMode="tail"
|
|
>
|
|
{entry.playerName}
|
|
{entry.isCurrentUser && (
|
|
<Text fontSize="$sm" color="$brand500"> (You)</Text>
|
|
)}
|
|
</Text>
|
|
<Text fontSize="$lg" fontWeight="$bold" color="$brand600">
|
|
{entry.score}
|
|
</Text>
|
|
</HStack>
|
|
|
|
<HStack justifyContent="space-between">
|
|
<Text fontSize="$sm" color="$gray600">
|
|
{entry.questionsAnswered} questions
|
|
</Text>
|
|
<Text fontSize="$sm" color="$gray600">
|
|
{formatSuccessRate(entry.successRate)} accuracy
|
|
</Text>
|
|
</HStack>
|
|
</VStack>
|
|
</HStack>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
export default Leaderboard
|
|
```
|
|
|
|
### Timer Component
|
|
|
|
```typescript
|
|
// packages/ui-components/src/components/Timer/Timer.tsx
|
|
import React, { useEffect, useState } from 'react'
|
|
import {
|
|
HStack,
|
|
VStack,
|
|
Text,
|
|
Box,
|
|
Progress,
|
|
ProgressFilledTrack,
|
|
} from '@gluestack-ui/themed'
|
|
import { knowFooleryTheme } from '../../theme'
|
|
|
|
export interface TimerProps {
|
|
timeRemaining: number // in seconds
|
|
totalTime?: number // in seconds, defaults to 30 minutes
|
|
onTimeExpire?: () => void
|
|
onWarning?: (timeLeft: number) => void
|
|
showProgress?: boolean
|
|
size?: 'sm' | 'md' | 'lg'
|
|
variant?: 'compact' | 'detailed'
|
|
}
|
|
|
|
export const Timer: React.FC<TimerProps> = ({
|
|
timeRemaining,
|
|
totalTime = 30 * 60, // 30 minutes default
|
|
onTimeExpire,
|
|
onWarning,
|
|
showProgress = true,
|
|
size = 'md',
|
|
variant = 'detailed',
|
|
}) => {
|
|
const [hasWarned5Min, setHasWarned5Min] = useState(false)
|
|
const [hasWarned1Min, setHasWarned1Min] = useState(false)
|
|
|
|
// Format time display
|
|
const formatTime = (seconds: number): string => {
|
|
const minutes = Math.floor(seconds / 60)
|
|
const remainingSeconds = seconds % 60
|
|
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
|
|
}
|
|
|
|
// Calculate progress percentage
|
|
const progressValue = ((totalTime - timeRemaining) / totalTime) * 100
|
|
|
|
// Determine timer state and colors
|
|
const isTimeCritical = timeRemaining <= 60 // 1 minute
|
|
const isTimeWarning = timeRemaining <= 300 // 5 minutes
|
|
|
|
const timerColor = isTimeCritical
|
|
? knowFooleryTheme.game.timer.critical
|
|
: isTimeWarning
|
|
? knowFooleryTheme.game.timer.warning
|
|
: knowFooleryTheme.game.timer.normal
|
|
|
|
const progressColor = isTimeCritical
|
|
? '$error500'
|
|
: isTimeWarning
|
|
? '$warning500'
|
|
: '$brand500'
|
|
|
|
// Font sizes based on size prop
|
|
const fontSizes = {
|
|
sm: { time: '$md', label: '$xs' },
|
|
md: { time: '$lg', label: '$sm' },
|
|
lg: { time: '$2xl', label: '$md' },
|
|
}
|
|
|
|
// Handle warnings and expiration
|
|
useEffect(() => {
|
|
if (timeRemaining === 0 && onTimeExpire) {
|
|
onTimeExpire()
|
|
} else if (timeRemaining <= 60 && !hasWarned1Min && onWarning) {
|
|
setHasWarned1Min(true)
|
|
onWarning(timeRemaining)
|
|
} else if (timeRemaining <= 300 && !hasWarned5Min && onWarning) {
|
|
setHasWarned5Min(true)
|
|
onWarning(timeRemaining)
|
|
}
|
|
}, [timeRemaining, onTimeExpire, onWarning, hasWarned1Min, hasWarned5Min])
|
|
|
|
if (variant === 'compact') {
|
|
return (
|
|
<HStack space="xs" alignItems="center" testID="timer-compact">
|
|
<Text fontSize="$md">⏱️</Text>
|
|
<Text
|
|
fontSize={fontSizes[size].time}
|
|
fontWeight="$semibold"
|
|
color={timerColor}
|
|
>
|
|
{formatTime(timeRemaining)}
|
|
</Text>
|
|
</HStack>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<VStack space="sm" testID="timer-detailed">
|
|
{/* Time display */}
|
|
<HStack justifyContent="center" alignItems="center" space="sm">
|
|
<Text fontSize="$lg">⏱️</Text>
|
|
<VStack space="xs" alignItems="center">
|
|
<Text
|
|
fontSize={fontSizes[size].time}
|
|
fontWeight="$bold"
|
|
color={timerColor}
|
|
testID="timer-display"
|
|
>
|
|
{formatTime(timeRemaining)}
|
|
</Text>
|
|
<Text
|
|
fontSize={fontSizes[size].label}
|
|
color="$gray600"
|
|
fontWeight="$medium"
|
|
>
|
|
Time Remaining
|
|
</Text>
|
|
</VStack>
|
|
</HStack>
|
|
|
|
{/* Progress bar */}
|
|
{showProgress && (
|
|
<Box>
|
|
<Progress value={progressValue} size="md" testID="timer-progress">
|
|
<ProgressFilledTrack bg={progressColor} />
|
|
</Progress>
|
|
{variant === 'detailed' && (
|
|
<HStack justifyContent="space-between" mt="$1">
|
|
<Text fontSize="$xs" color="$gray500">
|
|
0:00
|
|
</Text>
|
|
<Text fontSize="$xs" color="$gray500">
|
|
{formatTime(totalTime)}
|
|
</Text>
|
|
</HStack>
|
|
)}
|
|
</Box>
|
|
)}
|
|
|
|
{/* Warning messages */}
|
|
{isTimeCritical && (
|
|
<Box
|
|
bg="$error50"
|
|
p="$2"
|
|
rounded="$md"
|
|
borderLeftWidth={3}
|
|
borderLeftColor="$error500"
|
|
>
|
|
<Text fontSize="$sm" color="$error700" fontWeight="$semibold" textAlign="center">
|
|
⚠️ Less than 1 minute remaining!
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
|
|
{isTimeWarning && !isTimeCritical && (
|
|
<Box
|
|
bg="$warning50"
|
|
p="$2"
|
|
rounded="$md"
|
|
borderLeftWidth={3}
|
|
borderLeftColor="$warning500"
|
|
>
|
|
<Text fontSize="$sm" color="$warning700" fontWeight="$semibold" textAlign="center">
|
|
⏰ 5 minutes or less remaining
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
</VStack>
|
|
)
|
|
}
|
|
|
|
export default Timer
|
|
```
|
|
|
|
## Platform-Specific Adaptations
|
|
|
|
### Web-Specific Optimizations
|
|
|
|
```typescript
|
|
// apps/web/src/components/WebGameCard.tsx
|
|
import React from 'react'
|
|
import { GameCard, GameCardProps } from '@knowfoolery/ui-components'
|
|
import { useHotkeys } from 'react-hotkeys-hook'
|
|
|
|
interface WebGameCardProps extends GameCardProps {
|
|
enableKeyboardShortcuts?: boolean
|
|
}
|
|
|
|
export const WebGameCard: React.FC<WebGameCardProps> = ({
|
|
enableKeyboardShortcuts = true,
|
|
onSubmitAnswer,
|
|
onRequestHint,
|
|
...props
|
|
}) => {
|
|
// Keyboard shortcuts for web
|
|
useHotkeys('enter', () => {
|
|
const answerInput = document.querySelector('[data-testid="answer-input"] input') as HTMLInputElement
|
|
if (answerInput && answerInput.value.trim()) {
|
|
onSubmitAnswer(answerInput.value.trim())
|
|
}
|
|
}, { enabled: enableKeyboardShortcuts })
|
|
|
|
useHotkeys('ctrl+h, cmd+h', (event) => {
|
|
event.preventDefault()
|
|
onRequestHint()
|
|
}, { enabled: enableKeyboardShortcuts })
|
|
|
|
return (
|
|
<div className="web-game-card-wrapper">
|
|
<GameCard
|
|
{...props}
|
|
onSubmitAnswer={onSubmitAnswer}
|
|
onRequestHint={onRequestHint}
|
|
/>
|
|
|
|
{enableKeyboardShortcuts && (
|
|
<div className="keyboard-hints">
|
|
<small className="text-gray-500">
|
|
Press Enter to submit • Ctrl/Cmd + H for hint
|
|
</small>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
### Mobile-Specific Optimizations
|
|
|
|
```typescript
|
|
// apps/mobile/src/components/MobileGameCard.tsx
|
|
import React from 'react'
|
|
import { Vibration, Platform } from 'react-native'
|
|
import { GameCard, GameCardProps } from '@knowfoolery/ui-components'
|
|
|
|
interface MobileGameCardProps extends GameCardProps {
|
|
enableHaptics?: boolean
|
|
}
|
|
|
|
export const MobileGameCard: React.FC<MobileGameCardProps> = ({
|
|
enableHaptics = true,
|
|
onSubmitAnswer,
|
|
onRequestHint,
|
|
onTimeExpire,
|
|
...props
|
|
}) => {
|
|
const handleSubmitWithHaptics = async (answer: string) => {
|
|
if (enableHaptics && Platform.OS === 'ios') {
|
|
// Use iOS haptics
|
|
const { ImpactFeedbackGenerator } = require('expo-haptics')
|
|
ImpactFeedbackGenerator.impactAsync(ImpactFeedbackGenerator.ImpactFeedbackStyle.Medium)
|
|
} else if (enableHaptics && Platform.OS === 'android') {
|
|
// Use Android vibration
|
|
Vibration.vibrate(50)
|
|
}
|
|
|
|
await onSubmitAnswer(answer)
|
|
}
|
|
|
|
const handleHintWithHaptics = async () => {
|
|
if (enableHaptics) {
|
|
if (Platform.OS === 'ios') {
|
|
const { NotificationFeedbackGenerator } = require('expo-haptics')
|
|
NotificationFeedbackGenerator.notificationAsync(
|
|
NotificationFeedbackGenerator.NotificationFeedbackType.Warning
|
|
)
|
|
} else if (Platform.OS === 'android') {
|
|
Vibration.vibrate([0, 100, 50, 100])
|
|
}
|
|
}
|
|
|
|
await onRequestHint()
|
|
}
|
|
|
|
const handleTimeExpireWithHaptics = () => {
|
|
if (enableHaptics) {
|
|
if (Platform.OS === 'ios') {
|
|
const { NotificationFeedbackGenerator } = require('expo-haptics')
|
|
NotificationFeedbackGenerator.notificationAsync(
|
|
NotificationFeedbackGenerator.NotificationFeedbackType.Error
|
|
)
|
|
} else if (Platform.OS === 'android') {
|
|
Vibration.vibrate([0, 200, 100, 200, 100, 200])
|
|
}
|
|
}
|
|
|
|
onTimeExpire?.()
|
|
}
|
|
|
|
return (
|
|
<GameCard
|
|
{...props}
|
|
onSubmitAnswer={handleSubmitWithHaptics}
|
|
onRequestHint={handleHintWithHaptics}
|
|
onTimeExpire={handleTimeExpireWithHaptics}
|
|
/>
|
|
)
|
|
}
|
|
```
|
|
|
|
## Responsive Design Strategy
|
|
|
|
### Breakpoint System
|
|
|
|
```typescript
|
|
// packages/ui-components/src/theme/breakpoints.ts
|
|
export const breakpoints = {
|
|
sm: 640, // Mobile landscape
|
|
md: 768, // Tablet portrait
|
|
lg: 1024, // Tablet landscape / Desktop
|
|
xl: 1280, // Large desktop
|
|
'2xl': 1536, // Extra large desktop
|
|
}
|
|
|
|
export const useResponsiveValue = <T>(values: {
|
|
base: T
|
|
sm?: T
|
|
md?: T
|
|
lg?: T
|
|
xl?: T
|
|
}) => {
|
|
const [screenWidth, setScreenWidth] = useState(
|
|
typeof window !== 'undefined' ? window.innerWidth : breakpoints.lg
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') return
|
|
|
|
const handleResize = () => setScreenWidth(window.innerWidth)
|
|
window.addEventListener('resize', handleResize)
|
|
|
|
return () => window.removeEventListener('resize', handleResize)
|
|
}, [])
|
|
|
|
if (screenWidth >= breakpoints.xl && values.xl !== undefined) return values.xl
|
|
if (screenWidth >= breakpoints.lg && values.lg !== undefined) return values.lg
|
|
if (screenWidth >= breakpoints.md && values.md !== undefined) return values.md
|
|
if (screenWidth >= breakpoints.sm && values.sm !== undefined) return values.sm
|
|
|
|
return values.base
|
|
}
|
|
|
|
// Usage example
|
|
export const ResponsiveGameLayout: React.FC = ({ children }) => {
|
|
const padding = useResponsiveValue({
|
|
base: '$4',
|
|
md: '$6',
|
|
lg: '$8',
|
|
})
|
|
|
|
const columns = useResponsiveValue({
|
|
base: 1,
|
|
lg: 2,
|
|
})
|
|
|
|
return (
|
|
<Box p={padding}>
|
|
<VStack space="lg" flex={1}>
|
|
{children}
|
|
</VStack>
|
|
</Box>
|
|
)
|
|
}
|
|
```
|
|
|
|
## Testing Strategy for Cross-Platform Components
|
|
|
|
### Component Testing
|
|
|
|
```typescript
|
|
// packages/ui-components/src/components/GameCard/GameCard.test.tsx
|
|
import React from 'react'
|
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|
import { GluestackUIProvider } from '@gluestack-ui/themed'
|
|
import { config } from '../../../gluestack-ui.config'
|
|
import { GameCard, GameCardProps } from './GameCard'
|
|
|
|
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
|
<GluestackUIProvider config={config}>
|
|
{children}
|
|
</GluestackUIProvider>
|
|
)
|
|
|
|
const defaultProps: GameCardProps = {
|
|
question: "What is the capital of France?",
|
|
theme: "Geography",
|
|
difficulty: "medium",
|
|
timeRemaining: 1800,
|
|
attemptsLeft: 3,
|
|
currentScore: 0,
|
|
hintUsed: false,
|
|
onSubmitAnswer: jest.fn(),
|
|
onRequestHint: jest.fn(),
|
|
}
|
|
|
|
describe('GameCard Component', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks()
|
|
})
|
|
|
|
describe('Cross-Platform Rendering', () => {
|
|
it('renders correctly with all required props', () => {
|
|
render(<GameCard {...defaultProps} />, { wrapper: TestWrapper })
|
|
|
|
expect(screen.getByTestId('game-card')).toBeInTheDocument()
|
|
expect(screen.getByTestId('question-text')).toHaveTextContent(defaultProps.question)
|
|
expect(screen.getByTestId('theme-badge')).toHaveTextContent(defaultProps.theme)
|
|
expect(screen.getByTestId('timer-display')).toBeInTheDocument()
|
|
})
|
|
|
|
it('handles different screen sizes appropriately', () => {
|
|
// Mock different viewport sizes
|
|
const viewports = [
|
|
{ width: 375, height: 667 }, // Mobile
|
|
{ width: 768, height: 1024 }, // Tablet
|
|
{ width: 1440, height: 900 }, // Desktop
|
|
]
|
|
|
|
viewports.forEach(viewport => {
|
|
// Mock window dimensions
|
|
Object.defineProperty(window, 'innerWidth', {
|
|
writable: true,
|
|
configurable: true,
|
|
value: viewport.width,
|
|
})
|
|
|
|
render(<GameCard {...defaultProps} />, { wrapper: TestWrapper })
|
|
|
|
const gameCard = screen.getByTestId('game-card')
|
|
expect(gameCard).toBeInTheDocument()
|
|
|
|
// Component should maintain functionality across all sizes
|
|
expect(screen.getByTestId('answer-input')).toBeInTheDocument()
|
|
expect(screen.getByTestId('submit-button')).toBeInTheDocument()
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('Interactive Functionality', () => {
|
|
it('submits answer when submit button is pressed', async () => {
|
|
const mockOnSubmitAnswer = jest.fn().mockResolvedValue(undefined)
|
|
|
|
render(
|
|
<GameCard
|
|
{...defaultProps}
|
|
onSubmitAnswer={mockOnSubmitAnswer}
|
|
/>,
|
|
{ wrapper: TestWrapper }
|
|
)
|
|
|
|
const input = screen.getByTestId('answer-input')
|
|
const submitButton = screen.getByTestId('submit-button')
|
|
|
|
// Enter answer
|
|
fireEvent.changeText(input, 'Paris')
|
|
|
|
// Submit answer
|
|
fireEvent.press(submitButton)
|
|
|
|
await waitFor(() => {
|
|
expect(mockOnSubmitAnswer).toHaveBeenCalledWith('Paris')
|
|
})
|
|
})
|
|
|
|
it('requests hint when hint button is pressed', async () => {
|
|
const mockOnRequestHint = jest.fn().mockResolvedValue(undefined)
|
|
|
|
render(
|
|
<GameCard
|
|
{...defaultProps}
|
|
onRequestHint={mockOnRequestHint}
|
|
/>,
|
|
{ wrapper: TestWrapper }
|
|
)
|
|
|
|
const hintButton = screen.getByTestId('hint-button')
|
|
fireEvent.press(hintButton)
|
|
|
|
await waitFor(() => {
|
|
expect(mockOnRequestHint).toHaveBeenCalled()
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('Timer and State Management', () => {
|
|
it('displays timer with correct formatting', () => {
|
|
render(<GameCard {...defaultProps} timeRemaining={125} />, { wrapper: TestWrapper })
|
|
|
|
expect(screen.getByTestId('timer-display')).toHaveTextContent('⏱️ 2:05')
|
|
})
|
|
|
|
it('shows warning colors when time is low', () => {
|
|
render(<GameCard {...defaultProps} timeRemaining={30} />, { wrapper: TestWrapper })
|
|
|
|
const timer = screen.getByTestId('timer-display')
|
|
// Check if timer has warning/critical styling applied
|
|
expect(timer).toHaveStyle({ color: expect.stringContaining('#ef4444') })
|
|
})
|
|
})
|
|
})
|
|
```
|
|
|
|
This comprehensive Gluestack UI strategy ensures Know Foolery delivers a consistent, performant, and maintainable user experience across all platforms while leveraging the power of a unified component system. |