- 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
160 lines
6.1 KiB
TypeScript
160 lines
6.1 KiB
TypeScript
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>
|
|
);
|
|
}
|