Skip to content

Commit 3ee999d

Browse files
authored
Feat/show new achievements (#197)
1 parent b15c105 commit 3ee999d

2 files changed

Lines changed: 151 additions & 76 deletions

File tree

frontend/app/pages/AchievementsPage.tsx

Lines changed: 141 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import React, { useEffect, useMemo, useCallback } from "react";
1+
import React, { useMemo, useCallback } from "react";
22
import { View, Text, FlatList, StyleSheet } from "react-native";
33
import Animated, { FadeInRight, FadeInLeft } from "react-native-reanimated";
44
import { useIsFocused, useFocusEffect } from "@react-navigation/native";
55
import { useCachedWindowDimensions } from "../hooks/useCachedDimensions";
66
import {
77
useAchievement,
88
useAchievementsLastViewed,
9+
useIsAchievementUnseen,
910
} from "../stores/useAchievementsStore";
1011
import { useImages } from "../hooks/useImages";
1112
import achievementJson from "../configs/achievements.json";
@@ -35,7 +36,6 @@ const ICON_CONTAINER_BOTTOM = 38;
3536
const ICON_CONTAINER_LEFT = 33;
3637
const ICON_SIZE = 50;
3738
const TILE_HORIZONTAL_MARGIN = 4;
38-
const HEADER_TO_ROW_SPACING = 8;
3939
const GRADIENT_FRACTION = 0.14; // of screen width, clamped below
4040

4141
const styles = StyleSheet.create({
@@ -144,6 +144,133 @@ const styles = StyleSheet.create({
144144
},
145145
});
146146

147+
const AchievementItem: React.FC<{
148+
achievement: any;
149+
categoryId: string;
150+
achievementIndex: number;
151+
progress: number;
152+
getImage: (imageName: string) => any;
153+
renderAchievementName: (name: string) => React.ReactNode;
154+
}> = ({
155+
achievement,
156+
categoryId,
157+
achievementIndex,
158+
progress,
159+
getImage,
160+
renderAchievementName,
161+
}) => {
162+
const isUnseen = useIsAchievementUnseen(achievement.id);
163+
164+
return (
165+
<Animated.View
166+
style={styles.achievementItem}
167+
key={achievementIndex}
168+
entering={FadeInRight}
169+
>
170+
<Canvas
171+
key={`achievement-bg-${categoryId}-${achievement.id}`}
172+
style={styles.fillCanvas}
173+
>
174+
<Image
175+
image={getImage("achievements.tile.locked")}
176+
fit="fill"
177+
x={0}
178+
y={0}
179+
width={ACHIEVEMENT_TILE_WIDTH}
180+
height={ACHIEVEMENT_TILE_HEIGHT}
181+
/>
182+
</Canvas>
183+
{progress > 0 && (
184+
<View
185+
style={[
186+
styles.progressOverlayBase,
187+
{
188+
backgroundColor: "#6dcd64",
189+
width: `${progress}%`,
190+
},
191+
]}
192+
/>
193+
)}
194+
{progress > 0 && (
195+
<View style={styles.progressOverlayImageWrapper}>
196+
<Canvas
197+
key={`achievement-overlay-${categoryId}-${achievement.id}-${progress}`}
198+
style={styles.fillCanvas}
199+
>
200+
<Image
201+
image={getImage(
202+
progress === 100
203+
? "achievements.tile.achieved"
204+
: "achievements.tile.overlay",
205+
)}
206+
fit="fill"
207+
x={0}
208+
y={0}
209+
width={ACHIEVEMENT_TILE_WIDTH}
210+
height={ACHIEVEMENT_TILE_HEIGHT}
211+
/>
212+
</Canvas>
213+
</View>
214+
)}
215+
{progress === 100 && isUnseen && (
216+
<View
217+
style={{
218+
position: "absolute",
219+
bottom: 8,
220+
left: 0,
221+
right: 0,
222+
justifyContent: "center",
223+
alignItems: "center",
224+
zIndex: 10,
225+
}}
226+
>
227+
<View
228+
style={{
229+
backgroundColor: "#7c3aed",
230+
paddingHorizontal: 12,
231+
paddingVertical: 3,
232+
borderRadius: 6,
233+
}}
234+
>
235+
<Text
236+
style={{
237+
color: "#fff7ff",
238+
fontSize: 15,
239+
fontFamily: PIXEL_FONT,
240+
fontWeight: "bold",
241+
}}
242+
>
243+
NEW!
244+
</Text>
245+
</View>
246+
</View>
247+
)}
248+
<View style={styles.achievementNameContainer}>
249+
{renderAchievementName(achievement.name)}
250+
</View>
251+
<View style={styles.achievementIconContainer}>
252+
<Canvas
253+
key={`achievement-icon-${categoryId}-${achievement.id}`}
254+
style={styles.fillCanvas}
255+
>
256+
<Image
257+
image={getImage(achievement.image)}
258+
fit="contain"
259+
x={0}
260+
y={0}
261+
width={ICON_SIZE}
262+
height={ICON_SIZE}
263+
sampling={{
264+
filter: FilterMode.Nearest,
265+
mipmap: MipmapMode.Nearest,
266+
}}
267+
/>
268+
</Canvas>
269+
</View>
270+
</Animated.View>
271+
);
272+
};
273+
147274
export const AchievementsPage: React.FC = () => {
148275
const isFocused = useIsFocused();
149276
const { achievementsProgress } = useAchievement();
@@ -153,7 +280,10 @@ export const AchievementsPage: React.FC = () => {
153280

154281
useFocusEffect(
155282
useCallback(() => {
156-
setAchievementsLastViewedNow();
283+
// Mark achievements as viewed when the user leaves the page
284+
return () => {
285+
setAchievementsLastViewedNow();
286+
};
157287
}, [setAchievementsLastViewedNow]),
158288
);
159289

@@ -276,79 +406,14 @@ export const AchievementsPage: React.FC = () => {
276406
maxToRenderPerBatch={6}
277407
windowSize={10}
278408
renderItem={({ item: achievement, index: achievementIndex }) => (
279-
<Animated.View
280-
style={styles.achievementItem}
281-
key={achievementIndex}
282-
entering={FadeInRight}
283-
>
284-
<Canvas
285-
key={`achievement-bg-${item.id}-${achievement.id}`}
286-
style={styles.fillCanvas}
287-
>
288-
<Image
289-
image={getImage("achievements.tile.locked")}
290-
fit="fill"
291-
x={0}
292-
y={0}
293-
width={ACHIEVEMENT_TILE_WIDTH}
294-
height={ACHIEVEMENT_TILE_HEIGHT}
295-
/>
296-
</Canvas>
297-
{achievementsProgress[achievement.id] > 0 && (
298-
<View
299-
style={[
300-
styles.progressOverlayBase,
301-
{
302-
backgroundColor: "#6dcd64",
303-
width: `${achievementsProgress[achievement.id]}%`,
304-
},
305-
]}
306-
/>
307-
)}
308-
{achievementsProgress[achievement.id] > 0 && (
309-
<View style={styles.progressOverlayImageWrapper}>
310-
<Canvas
311-
key={`achievement-overlay-${item.id}-${achievement.id}-${achievementsProgress[achievement.id]}`}
312-
style={styles.fillCanvas}
313-
>
314-
<Image
315-
image={getImage(
316-
achievementsProgress[achievement.id] === 100
317-
? "achievements.tile.achieved"
318-
: "achievements.tile.overlay",
319-
)}
320-
fit="fill"
321-
x={0}
322-
y={0}
323-
width={ACHIEVEMENT_TILE_WIDTH}
324-
height={ACHIEVEMENT_TILE_HEIGHT}
325-
/>
326-
</Canvas>
327-
</View>
328-
)}
329-
<View style={styles.achievementNameContainer}>
330-
{renderAchievementName(achievement.name)}
331-
</View>
332-
<View style={styles.achievementIconContainer}>
333-
<Canvas
334-
key={`achievement-icon-${item.id}-${achievement.id}`}
335-
style={styles.fillCanvas}
336-
>
337-
<Image
338-
image={getImage(achievement.image)}
339-
fit="contain"
340-
x={0}
341-
y={0}
342-
width={ICON_SIZE}
343-
height={ICON_SIZE}
344-
sampling={{
345-
filter: FilterMode.Nearest,
346-
mipmap: MipmapMode.Nearest,
347-
}}
348-
/>
349-
</Canvas>
350-
</View>
351-
</Animated.View>
409+
<AchievementItem
410+
achievement={achievement}
411+
categoryId={item.id}
412+
achievementIndex={achievementIndex}
413+
progress={achievementsProgress[achievement.id] || 0}
414+
getImage={getImage}
415+
renderAchievementName={renderAchievementName}
416+
/>
352417
)}
353418
/>
354419
</View>

frontend/app/stores/useAchievementsStore.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,3 +272,13 @@ export const useAchievementsHasUnseen = (): boolean => {
272272
return false;
273273
}) as boolean;
274274
};
275+
276+
export const useIsAchievementUnseen = (achievementId: number): boolean => {
277+
return useAchievementsStore((state) => {
278+
const lastViewed = state.lastViewedAt;
279+
const unlockedAt = state.achievementsUnlockedAt[achievementId] || 0;
280+
const progress = state.achievementsProgress[achievementId] || 0;
281+
282+
return progress >= 100 && unlockedAt > 0 && unlockedAt > lastViewed;
283+
}) as boolean;
284+
};

0 commit comments

Comments
 (0)