diff --git a/.github/workflows/api-staging-cd.yml b/.github/workflows/api-staging-cd.yml index f1411eef..df94bdfa 100644 --- a/.github/workflows/api-staging-cd.yml +++ b/.github/workflows/api-staging-cd.yml @@ -22,29 +22,29 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - - id: 'setup-qemu' + - id: "setup-qemu" name: Set up QEMU uses: docker/setup-qemu-action@v3 - - id: 'docker-buildx-setup' + - id: "docker-buildx-setup" name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - id: 'auth' - name: 'Authenticate to Google Cloud' - uses: 'google-github-actions/auth@v2' + - id: "auth" + name: "Authenticate to Google Cloud" + uses: "google-github-actions/auth@v2" with: create_credentials_file: true token_format: access_token - workload_identity_provider: 'projects/5685154754/locations/global/workloadIdentityPools/cd-beerpong/providers/github-actions' - service_account: 'cd-beerpong@beer-pong-441815.iam.gserviceaccount.com' - - id: 'login-gar' + workload_identity_provider: "projects/5685154754/locations/global/workloadIdentityPools/cd-beerpong/providers/github-actions" + service_account: "cd-beerpong@beer-pong-441815.iam.gserviceaccount.com" + - id: "login-gar" name: "Login to GAR" uses: docker/login-action@v3 with: registry: europe-west10-docker.pkg.dev/beer-pong-441815/api-beerpong username: oauth2accesstoken password: ${{ steps.auth.outputs.access_token }} - - id: 'build-and-push' - name: 'Build and Push docker Image' + - id: "build-and-push" + name: "Build and Push docker Image" uses: docker/build-push-action@v6 with: push: true @@ -66,21 +66,21 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - - id: 'auth' - name: 'Authenticate to Google Cloud' - uses: 'google-github-actions/auth@v2' + - id: "auth" + name: "Authenticate to Google Cloud" + uses: "google-github-actions/auth@v2" with: create_credentials_file: true token_format: access_token - workload_identity_provider: 'projects/5685154754/locations/global/workloadIdentityPools/cd-beerpong/providers/github-actions' - service_account: 'cd-beerpong@beer-pong-441815.iam.gserviceaccount.com' - - id: 'deploy' - uses: 'google-github-actions/deploy-cloudrun@v2' + workload_identity_provider: "projects/5685154754/locations/global/workloadIdentityPools/cd-beerpong/providers/github-actions" + service_account: "cd-beerpong@beer-pong-441815.iam.gserviceaccount.com" + - id: "deploy" + uses: "google-github-actions/deploy-cloudrun@v2" with: - service: 'api-springboot-staging' - image: 'europe-west10-docker.pkg.dev/beer-pong-441815/api-beerpong/api-staging:${{ github.sha }}' + service: "api-springboot-staging" + image: "europe-west10-docker.pkg.dev/beer-pong-441815/api-beerpong/api-staging:${{ github.sha }}" region: europe-west10 - flags: '--port=8080 --add-cloudsql-instances=beer-pong-441815:europe-west10:beerpong-staging-db --no-cpu-throttling --min-instances 0 --max-instances 1 --allow-unauthenticated' + flags: "--port=8080 --add-cloudsql-instances=beer-pong-441815:europe-west10:beerpong-staging-db --no-cpu-throttling --min-instances 0 --max-instances 1 --allow-unauthenticated" env_vars: | POSTGRES_USER=postgres POSTGRES_URL=jdbc:postgresql:///beerpong?cloudSqlInstance=beer-pong-441815:europe-west10:beerpong-staging-db&socketFactory=com.google.cloud.sql.postgres.SocketFactory diff --git a/mobile-app/app.json b/mobile-app/app.json index 0ba772f2..74d8a5f4 100644 --- a/mobile-app/app.json +++ b/mobile-app/app.json @@ -2,7 +2,7 @@ "expo": { "name": "beerpong-tournament", "slug": "mobile-app", - "version": "1.0.7", + "version": "1.0.8", "orientation": "portrait", "icon": "./assets/images/icon.png", "scheme": "myapp", diff --git a/mobile-app/app/(tabs)/_layout.tsx b/mobile-app/app/(tabs)/_layout.tsx index 26f4ad6d..1b024ee3 100644 --- a/mobile-app/app/(tabs)/_layout.tsx +++ b/mobile-app/app/(tabs)/_layout.tsx @@ -3,6 +3,7 @@ import React from 'react'; import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; import { useGroupQuery } from '@/api/calls/groupHooks'; +import { env } from '@/api/env'; import { navStyles } from '@/app/navigation/navStyles'; import { useNavigation } from '@/app/navigation/useNavigation'; import { Colors } from '@/constants/Colors'; @@ -86,16 +87,22 @@ export default function TabLayout() { ...groupHeader, }} /> - ( - - ), - ...groupHeader, - }} - /> + {env.isDev && ( + ( + + ), + ...groupHeader, + }} + /> + )} i.profile!.name!); + const { mutateAsync } = useCreatePlayerMutation(); async function onSubmit(player: { name: string }) { @@ -22,6 +31,7 @@ export default function Page() { seasonId, name: player.name, }); + showSuccessToast(`Created player "${player.name}".`); nav.goBack(); } catch (err) { ConsoleLogger.error('failed to create player:', err); @@ -29,5 +39,10 @@ export default function Page() { } } - return ; + return ( + + ); } diff --git a/mobile-app/app/debugLog.tsx b/mobile-app/app/debugLog.tsx index 8a6054cb..ed039834 100644 --- a/mobile-app/app/debugLog.tsx +++ b/mobile-app/app/debugLog.tsx @@ -4,7 +4,6 @@ import React, { useEffect, useState } from 'react'; import { ScrollView } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; -import { env } from '@/api/env'; import { useApi } from '@/api/utils/create-api'; import { navStyles } from '@/app/navigation/navStyles'; import { Heading } from '@/components/Menu/MenuSection'; @@ -63,11 +62,14 @@ export default function Page() { - Debug Logs{' '} - {env.isDev ? (isRealtimeOpen ? '✅' : '❌') : ''} + Web Socket{' '} + {isRealtimeOpen + ? 'connected ✅' + : 'disconnected ❌'} } /> + {logs.map((i, idx) => ( diff --git a/mobile-app/app/newMatchPoints.tsx b/mobile-app/app/newMatchPoints.tsx index 74ceb89e..57711cd4 100644 --- a/mobile-app/app/newMatchPoints.tsx +++ b/mobile-app/app/newMatchPoints.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { useCreateMatchMutation } from '@/api/calls/matchHooks'; import { usePlayersQuery } from '@/api/calls/playerHooks'; @@ -6,7 +6,10 @@ import { useMoves } from '@/api/calls/ruleHooks'; import { useGroup } from '@/api/calls/seasonHooks'; import { TeamMember } from '@/api/utils/matchDtoToMatch'; import { useNavigation } from '@/app/navigation/useNavigation'; +import AssignFinishModeModal from '@/components/AssignFinishMoveModal'; +import AssignPointsToPlayerModal from '@/components/AssignPointsToPlayerModal'; import CreateMatchAssignPoints from '@/components/screens/CreateMatchAssignPoints'; +import { Feature } from '@/constants/Features'; import { showErrorToast } from '@/toast'; import { ConsoleLogger } from '@/utils/logging'; import { useMatchDraftStore } from '@/zustand/matchDraftStore'; @@ -16,6 +19,10 @@ export default function Page() { const matchDraft = useMatchDraftStore(); + const [playerIdx, setPlayerIdx] = useState(0); + + const [showFinishMoveModal, setShowFinishMoveModal] = useState(false); + const { mutateAsync } = useCreateMatchMutation(); const { groupId, seasonId } = useGroup(); @@ -44,7 +51,14 @@ export default function Page() { team: i.team, avatarUrl: profile.profile.avatarAsset?.url, name: profile.profile.name || 'Unknown', - points: 1, + points: i.moves.reduce( + (sum, j) => + sum + + j.count * + (allowedMoves.find((k) => k.id === j.moveId) + ?.pointsForScorer ?? 0), + 0 + ), change: 0.12, moves: allowedMoves.map((j) => { return { @@ -59,9 +73,23 @@ export default function Page() { }; }); + const finishes = teamMembers + .flatMap((i) => i.moves) + .filter((i) => i.isFinish); + + const numFinishes = finishes.reduce((sum, i) => sum + i.count, 0); + + const isValidGame = numFinishes === 1; + async function onSubmit() { if (!groupId || !seasonId) return; + if (Feature.POINTS_ASSIGNMENT_MODAL.isEnabled && !isValidGame) { + setShowFinishMoveModal(true); + + return; + } + try { await mutateAsync({ groupId, @@ -77,10 +105,62 @@ export default function Page() { } return ( - + <> + {Feature.POINTS_ASSIGNMENT_MODAL.isEnabled && ( + setPlayerIdx(null)} + playerIdx={playerIdx} + setPlayerIdx={setPlayerIdx} + match={{ + blueCups: players + .filter((i) => i.team === 'blue') + .map((i) => i.moves) + .flat() + .reduce((sum, i) => sum + i.count, 0), + redCups: players + .filter((i) => i.team === 'red') + .map((i) => i.moves) + .flat() + .reduce((sum, i) => sum + i.count, 0), + redTeam: teamMembers.filter((i) => i.team === 'red'), + blueTeam: teamMembers.filter((i) => i.team === 'blue'), + }} + editable + isVisible={playerIdx != null} + setMoveCount={matchDraft.actions.setMoveCount} + /> + )} + + matchDraft.actions.setMoveCount(playerId, moveId, 1) + } + onClose={() => setShowFinishMoveModal(false)} + isVisible={showFinishMoveModal} + match={{ + blueCups: players + .filter((i) => i.team === 'blue') + .map((i) => i.moves) + .flat() + .reduce((sum, i) => sum + i.count, 0), + redCups: players + .filter((i) => i.team === 'red') + .map((i) => i.moves) + .flat() + .reduce((sum, i) => sum + i.count, 0), + redTeam: teamMembers.filter((i) => i.team === 'red'), + blueTeam: teamMembers.filter((i) => i.team === 'blue'), + }} + /> + + setPlayerIdx( + teamMembers.findIndex((i) => i.id === player.id) + ) + } + /> + ); } diff --git a/mobile-app/components/AssignFinishMoveModal.tsx b/mobile-app/components/AssignFinishMoveModal.tsx new file mode 100644 index 00000000..76644537 --- /dev/null +++ b/mobile-app/components/AssignFinishMoveModal.tsx @@ -0,0 +1,144 @@ +import React, { useRef, useState } from 'react'; +import { SafeAreaView, ScrollView, TouchableOpacity, View } from 'react-native'; +import Modal from 'react-native-modal'; +import Swiper from 'react-native-swiper'; + +import { Match } from '@/api/utils/matchDtoToMatch'; + +import MatchPlayers from './MatchPlayers'; +import MenuItem from './Menu/MenuItem'; +import MenuSection from './Menu/MenuSection'; +import ModalDragHandle from './ModalDragHandle'; +import Text from './Text'; + +export interface AssignFinishModeModalProps { + isVisible?: boolean; + onClose?: () => void; + + onSubmit: (playerId: string, moveId: string) => void; + + match: Omit; +} +export default function AssignFinishModeModal({ + match, + isVisible = false, + onClose, + onSubmit, +}: AssignFinishModeModalProps) { + const players = match.blueTeam.concat(match.redTeam); + + const swiperRef = useRef(null); + + const [playerId, setPlayerId] = useState(null); + + const player = players.find((i) => i.id === playerId); + + const [page, setPage] = useState(0); + + return ( + + + + + + + + Who scored the final cup? + + + {}} + onPlayerPress={(player) => { + setPlayerId(player.id); + swiperRef.current?.scrollBy(1); + }} + /> + + + + + + + {player + ? `How did ${player.name} score the final cup?` + : 'Error'} + + + + {player?.moves + .filter((i) => i.isFinish) + .map((i) => ( + { + onSubmit(playerId!, i.id); + onClose?.(); + }} + /> + ))} + + + + + + + + ); +} diff --git a/mobile-app/components/AssignPointsToPlayerModal.tsx b/mobile-app/components/AssignPointsToPlayerModal.tsx new file mode 100644 index 00000000..c58d5803 --- /dev/null +++ b/mobile-app/components/AssignPointsToPlayerModal.tsx @@ -0,0 +1,218 @@ +import React, { useEffect, useRef } from 'react'; +import { SafeAreaView, TouchableOpacity, View } from 'react-native'; +import Modal from 'react-native-modal'; +import Swiper from 'react-native-swiper'; +import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; + +import { Match, TeamMember } from '@/api/utils/matchDtoToMatch'; +import { theme } from '@/theme'; + +import Avatar from './Avatar'; +import { HeaderItem } from './HeaderItem'; +import MatchVsHeader from './MatchVsHeader'; +import ModalDragHandle from './ModalDragHandle'; +import Text from './Text'; + +const showVsHeader = false; + +export interface AssignPointsToPlayerModalProps { + isVisible?: boolean; + onClose?: () => void; + + editable?: boolean; + + setMoveCount: (playerId: string, moveId: string, count: number) => void; + + match: Omit; + + playerIdx: number | null; + setPlayerIdx: (idx: number) => void; +} +export default function AssignPointsToPlayerModal({ + match, + isVisible = false, + onClose, + setMoveCount, + editable = false, + playerIdx, + setPlayerIdx, +}: AssignPointsToPlayerModalProps) { + const players = match.blueTeam.concat(match.redTeam); + + const swiperRef = useRef(null); + + function PlayerPage({ player }: { player: TeamMember }) { + return ( + + + + + {player.name} + + + How many cups did {player.name} score? + + + {player.moves.map((i, idx) => ( + + + {i.title} + + { + if (i.count > 0) + setMoveCount(player.id, i.id, i.count - 1); + }} + > + + + + + {i.count} + + { + setMoveCount(player.id, i.id, i.count + 1); + }} + > + + + + ))} + + ); + } + + // used for a scuffed hack to prevent the modal from reopening when clicking outside of it + const playerIdxRef = useRef(playerIdx); + + useEffect(() => { + playerIdxRef.current = playerIdx; + }, [playerIdx]); + + return ( + + + swiperRef.current?.scrollBy(-1) + } + onNextPress={ + playerIdx === players.length - 1 + ? undefined + : () => swiperRef.current?.scrollBy(+1) + } + headerRight={ + playerIdx === players.length - 1 ? ( + + Done + + ) : undefined + } + /> + {showVsHeader && ( + + )} + + { + setTimeout(() => { + // for some reason, onIndexChanged gets fired with 0 when dismissing the modal by clicking outside of it, leading to the modal opening again + // at this point, the component hasn't rerendered yet, so playerIdx will still be a non-null value, so we can't check against that. + // to work around this, we wait 0ms (which actually translates to a short wait) for the playerIdx to change to null. + // we have to use a ref for the playerIdx because we're inside a callback, and the value of playerIdx will be the same as when the callback was created (so non-null). + if (playerIdxRef.current != null) + setPlayerIdx(value); + }, 0); + }} + style={{ height: 0 }} + > + {players.map((i) => ( + + ))} + + + + ); +} diff --git a/mobile-app/components/Avatar.tsx b/mobile-app/components/Avatar.tsx index 2278bb8d..d9234e9d 100644 --- a/mobile-app/components/Avatar.tsx +++ b/mobile-app/components/Avatar.tsx @@ -93,10 +93,13 @@ export default function Avatar({ borderColor, }} > - {url ? ( + {url && ( - ) : ( - - {content || name?.[0] || ( - - )} - )} + + + {content || name?.[0] || ( + + )} + {canUpload && ( diff --git a/mobile-app/components/HeaderItem.tsx b/mobile-app/components/HeaderItem.tsx index 39758af5..a18093ad 100644 --- a/mobile-app/components/HeaderItem.tsx +++ b/mobile-app/components/HeaderItem.tsx @@ -1,25 +1,28 @@ import React from 'react'; -import { TouchableOpacity } from 'react-native'; +import { TouchableOpacity, TouchableOpacityProps } from 'react-native'; import { ThemedText } from '@/components/ThemedText'; import { theme } from '@/theme'; -export function HeaderItem({ - children, - noMargin = false, - onPress, - disabled = false, -}: { - children: string; +export interface HeaderItemProps extends TouchableOpacityProps { + children: React.ReactNode; noMargin?: boolean; onPress?: () => void; disabled?: boolean; -}) { +} + +export function HeaderItem({ + children, + noMargin = false, + onPress, + disabled = false, + ...rest +}: HeaderItemProps) { return ( <> - + {children} diff --git a/mobile-app/components/MatchPlayers/Player.tsx b/mobile-app/components/MatchPlayers/Player.tsx index ea7953c6..9debefab 100644 --- a/mobile-app/components/MatchPlayers/Player.tsx +++ b/mobile-app/components/MatchPlayers/Player.tsx @@ -44,6 +44,8 @@ export interface PlayerProps { editable?: boolean; setMoveCount: (playerId: string, moveId: string, count: number) => void; + + onPress?: () => void; } export default function Player({ player: { id, avatarUrl, team, name, points, change, moves }, @@ -53,6 +55,7 @@ export default function Player({ editable = false, setMoveCount, + onPress, }: PlayerProps) { const animation = useRef(new Animated.Value(0)).current; // start with height 0 @@ -88,9 +91,10 @@ export default function Player({ borderTopColor: theme.panel.light.active, }} onPress={ - editable + onPress ?? + (editable ? toggleCollapse - : () => nav.navigate('player', { id }) + : () => nav.navigate('player', { id })) } underlayColor={theme.panel.light.active} > diff --git a/mobile-app/components/MatchPlayers/index.tsx b/mobile-app/components/MatchPlayers/index.tsx index ea9372bb..6a0e0dac 100644 --- a/mobile-app/components/MatchPlayers/index.tsx +++ b/mobile-app/components/MatchPlayers/index.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import { TeamMember } from '@/api/utils/matchDtoToMatch'; +import { Feature } from '@/constants/Features'; import MenuSection from '../Menu/MenuSection'; import Player from './Player'; @@ -9,11 +10,13 @@ export interface MatchPlayersProps { editable?: boolean; players: TeamMember[]; setMoveCount: (playerId: string, moveId: string, count: number) => void; + onPlayerPress: (player: TeamMember) => void; } export default function MatchPlayers({ editable, players, setMoveCount, + onPlayerPress, }: MatchPlayersProps) { const [expandedId, setExpandedId] = useState(null); @@ -30,10 +33,19 @@ export default function MatchPlayers({ setExpandedId(value ? idx : null) } + onPress={ + Feature.POINTS_ASSIGNMENT_MODAL.isEnabled + ? () => onPlayerPress(i) + : undefined + } editable={editable} setMoveCount={setMoveCount} /> @@ -45,10 +57,19 @@ export default function MatchPlayers({ setExpandedId(value ? idx : null) } + onPress={ + Feature.POINTS_ASSIGNMENT_MODAL.isEnabled + ? () => onPlayerPress(i) + : undefined + } editable={editable} setMoveCount={setMoveCount} /> diff --git a/mobile-app/components/MatchVsHeader.tsx b/mobile-app/components/MatchVsHeader.tsx index 82aad00a..255e51bc 100644 --- a/mobile-app/components/MatchVsHeader.tsx +++ b/mobile-app/components/MatchVsHeader.tsx @@ -2,27 +2,83 @@ import React from 'react'; import { Text, View, ViewProps } from 'react-native'; import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; -import { Match } from '@/api/utils/matchDtoToMatch'; +import { Match, TeamMember } from '@/api/utils/matchDtoToMatch'; import Avatar from '@/components/Avatar'; import { theme } from '@/theme'; import { TeamId } from './screens/NewMatchAssignTeams'; -const MAX_ITEMS = 4; +function ScoreChip({ + winnerTeamId, + children, +}: { + winnerTeamId: 'red' | 'blue' | null; + children: React.ReactNode; +}) { + return ( + + {winnerTeamId && ( + + )} + + {children} + + + ); +} export interface MatchVsHeaderProps extends ViewProps { match: Omit; hasScore?: boolean; + + maxItems?: number; + + highlightedId?: string; } export default function MatchVsHeader({ match, hasScore = true, + maxItems = 4, + highlightedId, ...rest }: MatchVsHeaderProps) { const redWon = match.redCups > match.blueCups; - const winner: TeamId = + const winnerTeamId: TeamId = match.redCups > match.blueCups ? 'red' : match.redCups < match.blueCups @@ -41,94 +97,142 @@ export default function MatchVsHeader({ rest.style, ]} > - - {Array(Math.max(MAX_ITEMS - match.blueTeam.length, 0)) - .fill(null) - .map((_, index) => { - return ( - - ); - })} - {match.blueTeam.slice(0, MAX_ITEMS).map((i, index) => ( - MAX_ITEMS - ? '+' + (match.blueTeam.length - MAX_ITEMS + 1) - : undefined - } - name={i.name} - borderColor={theme.color.team.blue} - style={{ marginLeft: -16 }} - /> - ))} + + + + + {hasScore ? match.blueCups + ':' + match.redCups : 'vs'} + + - {winner && ( - - )} - - {hasScore ? match.blueCups + ':' + match.redCups : 'vs'} - + + + + ); +} - - {match.redTeam.slice(0, MAX_ITEMS).map((i, index) => ( - MAX_ITEMS - ? '+' + (match.redTeam.length - MAX_ITEMS + 1) - : undefined - } - name={i.name} - borderColor={theme.color.team.red} - style={{ marginRight: -16 }} - /> - ))} - {Array(Math.max(MAX_ITEMS - match.redTeam.length, 0)) - .fill(null) - .map((_, index) => { - return ( - - ); - })} - +function Team({ + highlightedId, + players, + maxItems, + color, + isCopy = false, +}: { + highlightedId?: string | null; + players: TeamMember[]; + maxItems: number; + color: 'red' | 'blue'; + + isCopy?: boolean; +}) { + const emptyAvatarsUsedForSpacing = Array( + Math.max(maxItems - players.length, 0) + ).fill(null); + + const displayedPlayers = players.slice(0, maxItems); + + return ( + + {color === 'blue' && + emptyAvatarsUsedForSpacing.map((_, index) => { + return ( + + ); + })} + {displayedPlayers.map((i, index) => ( + maxItems + ? '+' + (players.length - maxItems + 1) + : undefined + } + name={i.name} + borderColor={theme.color.team[color]} + style={{ + marginRight: color === 'red' ? -16 : undefined, + marginLeft: color === 'blue' ? -16 : undefined, + + opacity: isCopy ? (i.id === highlightedId ? 1 : 0) : 1, + zIndex: i.id === highlightedId ? 1 : undefined, + }} + /> + ))} + {color === 'red' && + emptyAvatarsUsedForSpacing.map((_, index) => { + return ( + + ); + })} ); } diff --git a/mobile-app/components/Menu/MenuSection.tsx b/mobile-app/components/Menu/MenuSection.tsx index 03bbd7e9..e6797601 100644 --- a/mobile-app/components/Menu/MenuSection.tsx +++ b/mobile-app/components/Menu/MenuSection.tsx @@ -62,7 +62,7 @@ export default function MenuSection({ color = 'light', }: MenuSectionProps) { return ( - + {title && } void; + onNextPress?: () => void; + + headerRight?: React.ReactNode; +}) { + return ( + + + {/* */} + + + {onBackPress && ( + + + + )} + {onNextPress && !headerRight && ( + + + + )} + {headerRight} + + + ); +} diff --git a/mobile-app/components/TextInput.tsx b/mobile-app/components/TextInput.tsx index 9410469e..2130eef9 100644 --- a/mobile-app/components/TextInput.tsx +++ b/mobile-app/components/TextInput.tsx @@ -1,47 +1,86 @@ -import { forwardRef } from 'react'; +import { forwardRef, useEffect } from 'react'; import { TextInput as ReactNativeTextInput, TextInputProps as ReactNativeTextInputProps, + View, } from 'react-native'; +import { triggerHapticBump } from '@/haptics'; import { theme } from '@/theme'; +import Text from './Text'; + export interface TextInputProps extends ReactNativeTextInputProps { required?: boolean; + + errorMessage?: string; } const TextInput = forwardRef( function TextInput( - { required = false, placeholder, ...rest }: TextInputProps, + { + required = false, + placeholder, + errorMessage, + ...rest + }: TextInputProps, ref ) { const color = '#fff'; + useEffect(() => { + if (errorMessage) { + triggerHapticBump('input:error'); + } + }, [errorMessage]); + return ( - + + + {errorMessage && ( + + {errorMessage} + + )} + ); } ); diff --git a/mobile-app/components/screens/CreateGroupAddMembers.tsx b/mobile-app/components/screens/CreateGroupAddMembers.tsx index 505a75ba..43d76120 100644 --- a/mobile-app/components/screens/CreateGroupAddMembers.tsx +++ b/mobile-app/components/screens/CreateGroupAddMembers.tsx @@ -35,12 +35,17 @@ export default function CreateGroupAddMembers({ const canBeCreated = members.length >= MIN_GROUP_MEMBERS; + const playerAlreadyExists = members.some((i) => i.name === value); + + const canSubmit = value.length > 0 && !playerAlreadyExists; + function onAddMember() { - if (value.length) { + if (canSubmit) { setMembers((prev) => [...prev, { name: value }]); + + setValue(''); + inputRef.current?.clear(); } - setValue(''); - inputRef.current?.clear(); } return ( @@ -50,7 +55,7 @@ export default function CreateGroupAddMembers({ headerRight: () => value.length > 0 || members.length < 2 ? ( Add @@ -81,9 +86,16 @@ export default function CreateGroupAddMembers({ style={{ backgroundColor: theme.color.bg, padding: 16, + + flexDirection: 'row', }} > void; onSubmit: () => void; + + onPlayerPress: (player: TeamMember) => void; } export default function CreateMatchAssignPoints({ players, setMoveCount, onSubmit, + onPlayerPress, }: CreateMatchAssignPointsProps) { - const finishes = players.flatMap((i) => i.moves).filter((i) => i.isFinish); - - const numFinishes = finishes.reduce((sum, i) => sum + i.count, 0); - - const isValidGame = numFinishes === 1; - const navigation = useNavigation(); return ( <> @@ -36,7 +33,7 @@ export default function CreateMatchAssignPoints({ options={{ ...navStyles, headerRight: () => ( - + Create ), @@ -91,6 +88,7 @@ export default function CreateMatchAssignPoints({ editable players={players} setMoveCount={setMoveCount} + onPlayerPress={onPlayerPress} /> diff --git a/mobile-app/components/screens/CreateNewPlayer.tsx b/mobile-app/components/screens/CreateNewPlayer.tsx index 9389054a..396774a5 100644 --- a/mobile-app/components/screens/CreateNewPlayer.tsx +++ b/mobile-app/components/screens/CreateNewPlayer.tsx @@ -8,12 +8,18 @@ import TextInput from '@/components/TextInput'; export interface CreateNewPlayerProps { onCreate: (player: { name: string }) => void; + existingPlayers?: string[]; } -export default function CreateNewPlayer({ onCreate }: CreateNewPlayerProps) { +export default function CreateNewPlayer({ + onCreate, + existingPlayers, +}: CreateNewPlayerProps) { const nav = useNavigation(); const [name, setName] = useState(''); + const playerAlreadyExists = existingPlayers?.includes(name); + return ( <> ( onCreate({ name })} > Create @@ -41,6 +47,11 @@ export default function CreateNewPlayer({ onCreate }: CreateNewPlayerProps) { style={{ marginHorizontal: 'auto' }} /> setName(text.trim())} diff --git a/mobile-app/components/screens/NewMatchAssignTeams.tsx b/mobile-app/components/screens/NewMatchAssignTeams.tsx index c7b1a615..1e3becc3 100644 --- a/mobile-app/components/screens/NewMatchAssignTeams.tsx +++ b/mobile-app/components/screens/NewMatchAssignTeams.tsx @@ -129,11 +129,14 @@ export default function NewMatchAssignTeams({ return (