feat: tambah sistem guide onboarding per fitur dengan GuideOverlay
- Buat komponen GuideOverlay dengan animasi fade+slide, arrow tooltip, dan dot indicator - Buat hook useGuide untuk menyimpan state guide per fitur via AsyncStorage - Sentralisasi semua step guide di lib/guideSteps.ts - Pasang guide pada 12 halaman: village-calendar, project detail, banner, group, position, member, announcement, discussion, division calendar/document/discussion, dan division task detail - Posisi card menggunakan cardTopRatio (rasio layar) untuk kompatibilitas berbagai ukuran device - Tambah styles guide dan village calendar di constants/Styles.ts
This commit is contained in:
159
components/GuideOverlay.tsx
Normal file
159
components/GuideOverlay.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import Text from '@/components/Text';
|
||||
import Styles from '@/constants/Styles';
|
||||
import { useTheme } from '@/providers/ThemeProvider';
|
||||
import { Feather } from '@expo/vector-icons';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Animated,
|
||||
Dimensions,
|
||||
Modal,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
export type GuideStep = {
|
||||
title: string;
|
||||
description: string;
|
||||
// posisi card: pixel absolut atau rasio layar (0.0-1.0), rasio lebih aman untuk layout dinamis
|
||||
cardTop?: number;
|
||||
cardTopRatio?: number;
|
||||
// arrow menunjuk ke atas (elemen ada di atas card) atau ke bawah (elemen ada di bawah card)
|
||||
arrowDirection?: 'up' | 'down' | 'none';
|
||||
// offset horizontal arrow dari kiri card (0.0-1.0), default 0.5 = tengah
|
||||
arrowOffset?: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
visible: boolean;
|
||||
steps: GuideStep[];
|
||||
onDismiss: () => void;
|
||||
};
|
||||
|
||||
const { height: SCREEN_H } = Dimensions.get('window');
|
||||
const CARD_MARGIN = 24;
|
||||
|
||||
export default function GuideOverlay({ visible, steps, onDismiss }: Props) {
|
||||
const { colors } = useTheme();
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
const slideAnim = useRef(new Animated.Value(20)).current;
|
||||
|
||||
const step = steps[currentStep];
|
||||
const isLast = currentStep === steps.length - 1;
|
||||
|
||||
const arrowDirection = step?.arrowDirection ?? 'none';
|
||||
const arrowOffset = step?.arrowOffset ?? 0.5;
|
||||
const cardTop = step?.cardTopRatio != null
|
||||
? SCREEN_H * step.cardTopRatio
|
||||
: (step?.cardTop ?? SCREEN_H * 0.35);
|
||||
|
||||
const cardPositionStyle = { top: cardTop };
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setCurrentStep(0);
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeAnim, { toValue: 1, duration: 300, useNativeDriver: true }),
|
||||
Animated.timing(slideAnim, { toValue: 0, duration: 300, useNativeDriver: true }),
|
||||
]).start();
|
||||
} else {
|
||||
fadeAnim.setValue(0);
|
||||
slideAnim.setValue(20);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
function animateStep(next: number) {
|
||||
Animated.sequence([
|
||||
Animated.timing(slideAnim, { toValue: 12, duration: 120, useNativeDriver: true }),
|
||||
Animated.timing(slideAnim, { toValue: 0, duration: 180, useNativeDriver: true }),
|
||||
]).start();
|
||||
setCurrentStep(next);
|
||||
}
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<Modal transparent visible={visible} animationType="none" onRequestClose={onDismiss}>
|
||||
<Animated.View style={[Styles.guideOverlay, { opacity: fadeAnim }]}>
|
||||
<Animated.View
|
||||
style={[
|
||||
Styles.guideCard,
|
||||
{ backgroundColor: colors.modalBackground, transform: [{ translateY: slideAnim }] },
|
||||
cardPositionStyle,
|
||||
]}
|
||||
>
|
||||
{/* Arrow atas — menunjuk ke elemen di atas card */}
|
||||
{arrowDirection === 'up' && (
|
||||
<View style={[Styles.guideArrowUp, {
|
||||
left: `${arrowOffset * 100}%` as any,
|
||||
borderBottomColor: colors.modalBackground,
|
||||
}]} />
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<View style={[Styles.rowItemsCenter, Styles.rowSpaceBetween, Styles.mb10]}>
|
||||
<View style={[Styles.guideBadge, { backgroundColor: colors.icon + '20' }]}>
|
||||
<Text style={[Styles.textSmallSemiBold, { color: colors.icon }]}>
|
||||
{currentStep + 1} / {steps.length}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity onPress={onDismiss} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
||||
<Feather name="x" size={18} color={colors.dimmed} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<Text style={[Styles.textDefaultSemiBold, Styles.mb05, { color: colors.text }]}>
|
||||
{step.title}
|
||||
</Text>
|
||||
<Text style={[Styles.textMediumNormal, { color: colors.text, lineHeight: 20 }]}>
|
||||
{step.description}
|
||||
</Text>
|
||||
|
||||
{/* Dot indicator */}
|
||||
<View style={[Styles.rowItemsCenter, Styles.guideDotRow]}>
|
||||
{steps.map((_, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={[Styles.guideDot, {
|
||||
backgroundColor: i === currentStep ? colors.icon : colors.icon + '30',
|
||||
}]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Footer */}
|
||||
<View style={[Styles.rowItemsCenter, Styles.rowSpaceBetween, Styles.mt10]}>
|
||||
{currentStep > 0 ? (
|
||||
<TouchableOpacity onPress={() => animateStep(currentStep - 1)} style={Styles.guideButtonSecondary}>
|
||||
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Kembali</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity onPress={onDismiss}>
|
||||
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Lewati</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => isLast ? onDismiss() : animateStep(currentStep + 1)}
|
||||
style={[Styles.guideButtonPrimary, { backgroundColor: colors.icon }]}
|
||||
>
|
||||
<Text style={[Styles.textMediumSemiBold, { color: '#ffffff' }]}>
|
||||
{isLast ? 'Selesai' : 'Lanjut'}
|
||||
</Text>
|
||||
{!isLast && <Feather name="arrow-right" size={14} color="#ffffff" style={{ marginLeft: 4 }} />}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Arrow bawah — menunjuk ke elemen di bawah card */}
|
||||
{arrowDirection === 'down' && (
|
||||
<View style={[Styles.guideArrowDown, {
|
||||
left: `${arrowOffset * 100}%` as any,
|
||||
borderTopColor: colors.modalBackground,
|
||||
}]} />
|
||||
)}
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user