diff --git a/app/(application)/announcement/index.tsx b/app/(application)/announcement/index.tsx index c62c9d7..0963911 100644 --- a/app/(application)/announcement/index.tsx +++ b/app/(application)/announcement/index.tsx @@ -1,9 +1,12 @@ +import GuideOverlay from "@/components/GuideOverlay"; import BorderBottomItem from "@/components/borderBottomItem"; import InputSearch from "@/components/inputSearch"; import SkeletonContent from "@/components/skeletonContent"; import Text from '@/components/Text'; import Styles from "@/constants/Styles"; import { apiGetAnnouncement } from "@/lib/api"; +import { GUIDE_ANNOUNCEMENT } from "@/lib/guideSteps"; +import { useGuide } from "@/lib/useGuide"; import { useAuthSession } from "@/providers/AuthProvider"; import { useTheme } from "@/providers/ThemeProvider"; import { MaterialIcons } from "@expo/vector-icons"; @@ -25,6 +28,7 @@ export default function Announcement() { const { colors } = useTheme(); const [search, setSearch] = useState('') const update = useSelector((state: any) => state.announcementUpdate) + const { visible: guideVisible, dismiss: dismissGuide } = useGuide('announcement') const arrSkeleton = Array.from({ length: 5 }, (_, index) => index) // TanStack Query Infinite Query @@ -78,6 +82,7 @@ export default function Announcement() { return ( + diff --git a/app/(application)/banner/index.tsx b/app/(application)/banner/index.tsx index c5560ab..4340b47 100644 --- a/app/(application)/banner/index.tsx +++ b/app/(application)/banner/index.tsx @@ -1,5 +1,5 @@ -import styles from "@/components/AppHeader" import AppHeader from "@/components/AppHeader" +import GuideOverlay from "@/components/GuideOverlay" import HeaderRightBannerList from "@/components/banner/headerBannerList" import BorderBottomItem from "@/components/borderBottomItem" import DrawerBottom from "@/components/drawerBottom" @@ -12,6 +12,8 @@ import { ConstEnv } from "@/constants/ConstEnv" import Styles from "@/constants/Styles" import { apiDeleteBanner, apiGetBanner } from "@/lib/api" import { setEntities } from "@/lib/bannerSlice" +import { GUIDE_BANNER } from "@/lib/guideSteps" +import { useGuide } from "@/lib/useGuide" import { useAuthSession } from "@/providers/AuthProvider" import { useTheme } from "@/providers/ThemeProvider" import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons" @@ -44,6 +46,7 @@ export default function BannerList() { const dispatch = useDispatch() const [refreshing, setRefreshing] = useState(false) const [loadingOpen, setLoadingOpen] = useState(false) + const { visible: guideVisible, dismiss: dismissGuide } = useGuide('banner') const [viewImg, setViewImg] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false) const queryClient = useQueryClient() @@ -147,6 +150,7 @@ export default function BannerList() { ) }} /> + (active == 'false' ? 'false' : 'true') const [refreshing, setRefreshing] = useState(false) + const { visible: guideVisible, dismiss: dismissGuide } = useGuide('discussion') const { data, @@ -85,6 +89,7 @@ export default function Discussion() { return ( + {/* Header controls */} {entityUser.role != "user" && entityUser.role != "coadmin" && ( diff --git a/app/(application)/division/[id]/(fitur-division)/calendar/index.tsx b/app/(application)/division/[id]/(fitur-division)/calendar/index.tsx index 7664f62..7baa0f8 100644 --- a/app/(application)/division/[id]/(fitur-division)/calendar/index.tsx +++ b/app/(application)/division/[id]/(fitur-division)/calendar/index.tsx @@ -1,4 +1,5 @@ import AppHeader from "@/components/AppHeader"; +import GuideOverlay from "@/components/GuideOverlay"; import HeaderRightCalendarList from "@/components/calendar/headerCalendarList"; import ItemDateCalendar from "@/components/calendar/itemDateCalendar"; import EventItem from "@/components/eventItem"; @@ -6,6 +7,8 @@ import Skeleton from "@/components/skeleton"; import Text from "@/components/Text"; import Styles from "@/constants/Styles"; import { apiGetCalendarByDateDivision, apiGetIndicatorCalendar } from "@/lib/api"; +import { GUIDE_DIVISION_CALENDAR } from "@/lib/guideSteps"; +import { useGuide } from "@/lib/useGuide"; import { useAuthSession } from "@/providers/AuthProvider"; import { useTheme } from "@/providers/ThemeProvider"; import { Feather } from "@expo/vector-icons"; @@ -46,6 +49,7 @@ export default function CalendarDivision() { const [loading, setLoading] = useState(true) const [loadingBtn, setLoadingBtn] = useState(false) const [refreshing, setRefreshing] = useState(false) + const { visible: guideVisible, dismiss: dismissGuide } = useGuide('division-calendar') async function handleLoad(loading: boolean) { @@ -150,6 +154,7 @@ export default function CalendarDivision() { ) }} /> + state.user) + const { visible: guideVisible, dismiss: dismissGuide } = useGuide('division-discussion') async function handleCheckMember() { try { @@ -96,6 +100,7 @@ export default function DiscussionDivision() { return ( + {((entityUser.role != "user" && entityUser.role != "coadmin") || isAdminDivision) && ( diff --git a/app/(application)/division/[id]/(fitur-division)/document/index.tsx b/app/(application)/division/[id]/(fitur-division)/document/index.tsx index 5104754..ca528a1 100644 --- a/app/(application)/division/[id]/(fitur-division)/document/index.tsx +++ b/app/(application)/division/[id]/(fitur-division)/document/index.tsx @@ -1,3 +1,4 @@ +import GuideOverlay from "@/components/GuideOverlay"; import ModalConfirmation from "@/components/ModalConfirmation"; import AppHeader from "@/components/AppHeader"; import { ButtonHeader } from "@/components/buttonHeader"; @@ -22,6 +23,8 @@ import { apiShareDocument, } from "@/lib/api"; import { setUpdateDokumen } from "@/lib/dokumenUpdate"; +import { GUIDE_DIVISION_DOCUMENT } from "@/lib/guideSteps"; +import { useGuide } from "@/lib/useGuide"; import { useAuthSession } from "@/providers/AuthProvider"; import { useTheme } from "@/providers/ThemeProvider"; import { @@ -90,6 +93,7 @@ export default function DocumentDivision() { const [isMemberDivision, setIsMemberDivision] = useState(false) const entityUser = useSelector((state: any) => state.user) const [showDeleteModal, setShowDeleteModal] = useState(false) + const { visible: guideVisible, dismiss: dismissGuide } = useGuide('division-document') const [bodyRename, setBodyRename] = useState({ id: "", name: "", @@ -415,6 +419,7 @@ export default function DocumentDivision() { ) }} /> + state.taskUpdate) const [refreshing, setRefreshing] = useState(false) const [isMemberDivision, setIsMemberDivision] = useState(false); + const { visible: guideVisible, dismiss: dismissGuide } = useGuide('division-task-detail') const [isAdminDivision, setIsAdminDivision] = useState(false); const entityUser = useSelector((state: any) => state.user); @@ -139,6 +143,7 @@ export default function DetailTaskDivision() { ) }} /> + state.groupUpdate) @@ -136,6 +140,7 @@ export default function Index() { return ( + (active == 'false' ? 'false' : 'true') const [refreshing, setRefreshing] = useState(false) + const { visible: guideVisible, dismiss: dismissGuide } = useGuide('member') // TanStack Query for Members with Infinite Scroll const { @@ -117,6 +121,7 @@ export default function Index() { return ( + state.positionUpdate) @@ -160,6 +164,7 @@ export default function Index() { return ( + state.user) const [refreshing, setRefreshing] = useState(false) + const { visible: guideVisible, dismiss: dismissGuide } = useGuide('project-detail') async function handleLoad(cat: 'data' | 'progress') { try { @@ -129,6 +133,7 @@ export default function DetailProject() { ) }} /> + v ); } + export default function VillageCalendar() { const { colors, activeTheme } = useTheme(); const { token, decryptToken } = useAuthSession(); + const { visible: guideVisible, dismiss: dismissGuide } = useGuide('village-calendar'); const [selected, setSelected] = useState(new Date()); const [data, setData] = useState([]); const [dataIndicator, setDataIndicator] = useState<{ calendar: string[], task: string[] }>({ calendar: [], task: [] }); @@ -194,6 +199,11 @@ export default function VillageCalendar() { ), }} /> + 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 ( + + + + {/* Arrow atas — menunjuk ke elemen di atas card */} + {arrowDirection === 'up' && ( + + )} + + {/* Header */} + + + + {currentStep + 1} / {steps.length} + + + + + + + + {/* Content */} + + {step.title} + + + {step.description} + + + {/* Dot indicator */} + + {steps.map((_, i) => ( + + ))} + + + {/* Footer */} + + {currentStep > 0 ? ( + animateStep(currentStep - 1)} style={Styles.guideButtonSecondary}> + Kembali + + ) : ( + + Lewati + + )} + + isLast ? onDismiss() : animateStep(currentStep + 1)} + style={[Styles.guideButtonPrimary, { backgroundColor: colors.icon }]} + > + + {isLast ? 'Selesai' : 'Lanjut'} + + {!isLast && } + + + + {/* Arrow bawah — menunjuk ke elemen di bawah card */} + {arrowDirection === 'down' && ( + + )} + + + + ); +} diff --git a/constants/Styles.ts b/constants/Styles.ts index b49195a..ada35aa 100644 --- a/constants/Styles.ts +++ b/constants/Styles.ts @@ -1170,6 +1170,76 @@ const Styles = StyleSheet.create({ fontStyle: 'italic', }, + // guide overlay + guideOverlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.6)', + justifyContent: 'center', + alignItems: 'center', + }, + guideCard: { + position: 'absolute', + left: 24, + right: 24, + borderRadius: 16, + padding: 20, + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.2, + shadowRadius: 12, + elevation: 8, + }, + guideBadge: { + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 20, + }, + guideDotRow: { + marginTop: 16, + gap: 6, + justifyContent: 'center', + }, + guideDot: { + width: 6, + height: 6, + borderRadius: 3, + }, + guideButtonPrimary: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 20, + }, + guideButtonSecondary: { + paddingHorizontal: 4, + paddingVertical: 8, + }, + guideArrowUp: { + position: 'absolute', + top: -10, + marginLeft: -8, + width: 0, + height: 0, + borderLeftWidth: 8, + borderRightWidth: 8, + borderBottomWidth: 10, + borderLeftColor: 'transparent', + borderRightColor: 'transparent', + }, + guideArrowDown: { + position: 'absolute', + bottom: -10, + marginLeft: -8, + width: 0, + height: 0, + borderLeftWidth: 8, + borderRightWidth: 8, + borderTopWidth: 10, + borderLeftColor: 'transparent', + borderRightColor: 'transparent', + }, + // village calendar & itemDateCalendar calendarDotRow: { flexDirection: 'row', diff --git a/lib/guideSteps.ts b/lib/guideSteps.ts new file mode 100644 index 0000000..64358f4 --- /dev/null +++ b/lib/guideSteps.ts @@ -0,0 +1,217 @@ +import { GuideStep } from "@/components/GuideOverlay"; + +export const GUIDE_VILLAGE_CALENDAR: GuideStep[] = [ + { + title: 'Kalender Umum', + description: 'Halaman ini menampilkan semua acara divisi dan kegiatan yang ada di desa kamu dalam satu kalender.', + cardTopRatio: 0.58, + arrowDirection: 'up', + arrowOffset: 0.5, + }, + { + title: 'Pilih Tanggal', + description: 'Ketuk tanggal pada kalender untuk melihat acara dan kegiatan yang berlangsung pada hari tersebut.', + cardTopRatio: 0.58, + arrowDirection: 'up', + arrowOffset: 0.5, + }, + { + title: 'Indikator Dot', + description: 'Titik ungu menandakan ada acara divisi, titik biru-abu menandakan ada kegiatan pada tanggal tersebut.', + cardTopRatio: 0.63, + arrowDirection: 'up', + arrowOffset: 0.12, + }, + { + title: 'Detail Event', + description: 'Ketuk salah satu acara atau kegiatan di bawah untuk melihat detail lengkapnya.', + cardTopRatio: 0.35, + arrowDirection: 'down', + arrowOffset: 0.5, + }, +]; + +export const GUIDE_DIVISION_DISCUSSION: GuideStep[] = [ + { + title: 'Diskusi Divisi', + description: 'Halaman ini menampilkan semua topik diskusi dalam divisi. Setiap card menunjukkan pembuat, deskripsi singkat, jumlah komentar, dan status diskusi.', + cardTopRatio: 0.35, + arrowDirection: 'none', + }, + { + title: 'Buka Diskusi', + description: 'Ketuk salah satu diskusi untuk membaca detail dan ikut berkomentar.', + cardTopRatio: 0.35, + arrowDirection: 'none', + }, +]; + +export const GUIDE_DIVISION_DOCUMENT: GuideStep[] = [ + { + title: 'Dokumen Divisi', + description: 'Halaman ini menampilkan semua file dan folder dokumen milik divisi kamu.', + cardTopRatio: 0.35, + arrowDirection: 'none', + }, + { + title: 'Buka File atau Folder', + description: 'Ketuk folder untuk masuk ke dalamnya, atau ketuk file untuk langsung membukanya.', + cardTopRatio: 0.35, + arrowDirection: 'none', + }, + { + title: 'Pilih & Kelola', + description: 'Centang file atau folder untuk memilihnya, lalu gunakan menu di bawah untuk menghapus, mengganti nama, atau membagikan ke divisi lain.', + cardTopRatio: 0.35, + arrowDirection: 'none', + }, +]; + +export const GUIDE_DIVISION_CALENDAR: GuideStep[] = [ + { + title: 'Kalender Divisi', + description: 'Halaman ini menampilkan semua acara yang ada di divisi kamu dalam satu kalender.', + cardTopRatio: 0.50, + arrowDirection: 'none', + arrowOffset: 0.5, + }, + { + title: 'Indikator Acara', + description: 'Garis di bawah tanggal menandakan ada acara pada hari tersebut. Ketuk tanggal untuk melihat daftarnya.', + cardTopRatio: 0.50, + arrowDirection: 'none', + arrowOffset: 0.55, + }, + { + title: 'Detail Acara', + description: 'Ketuk salah satu acara di bawah kalender untuk melihat detail lengkapnya.', + cardTopRatio: 0.35, + arrowDirection: 'down', + arrowOffset: 0.55, + }, +]; + +export const GUIDE_DISCUSSION: GuideStep[] = [ + { + title: 'Daftar Diskusi', + description: 'Halaman ini menampilkan semua topik diskusi. Setiap card menunjukkan judul, deskripsi singkat, jumlah komentar, dan status diskusi.', + cardTopRatio: 0.35, + arrowDirection: 'none', + }, + { + title: 'Buka Diskusi', + description: 'Ketuk salah satu diskusi untuk membaca detail dan ikut berkomentar.', + cardTopRatio: 0.35, + arrowDirection: 'none', + }, +]; + +export const GUIDE_ANNOUNCEMENT: GuideStep[] = [ + { + title: 'Daftar Pengumuman', + description: 'Halaman ini menampilkan semua pengumuman yang ada di desamu.', + cardTopRatio: 0.35, + arrowDirection: 'none', + }, + { + title: 'Detail Pengumuman', + description: 'Ketuk salah satu pengumuman untuk membaca isi lengkapnya.', + cardTopRatio: 0.35, + arrowDirection: 'none', + }, +]; + +export const GUIDE_MEMBER: GuideStep[] = [ + { + title: 'Daftar Anggota', + description: 'Halaman ini menampilkan semua anggota desa. Gunakan tab untuk memfilter anggota aktif atau tidak aktif.', + cardTopRatio: 0.35, + arrowDirection: 'none', + }, + { + title: 'Detail Anggota', + description: 'Ketuk salah satu anggota untuk melihat informasi lengkapnya seperti NIK, jabatan, lembaga, dan kontak.', + cardTopRatio: 0.35, + arrowDirection: 'none', + }, +]; + +export const GUIDE_POSITION: GuideStep[] = [ + { + title: 'Daftar Jabatan', + description: 'Halaman ini menampilkan semua jabatan yang terdaftar. Gunakan tab untuk memfilter jabatan aktif atau tidak aktif.', + cardTopRatio: 0.35, + arrowDirection: 'none', + }, + { + title: 'Menu Aksi', + description: 'Ketuk salah satu jabatan untuk membuka menu aksi — kamu bisa mengaktifkan/menonaktifkan atau mengedit nama jabatan.', + cardTopRatio: 0.35, + arrowDirection: 'none', + }, +]; + +export const GUIDE_GROUP: GuideStep[] = [ + { + title: 'Daftar Lembaga Desa', + description: 'Halaman ini menampilkan semua lembaga desa yang terdaftar. Gunakan tab untuk memfilter yang aktif atau tidak aktif.', + cardTopRatio: 0.35, + arrowDirection: 'none', + }, + { + title: 'Menu Aksi', + description: 'Ketuk salah satu lembaga untuk membuka menu aksi — kamu bisa mengaktifkan/menonaktifkan atau mengedit nama lembaga.', + cardTopRatio: 0.35, + arrowDirection: 'none', + }, +]; + +export const GUIDE_BANNER: GuideStep[] = [ + { + title: 'Daftar Banner', + description: 'Halaman ini menampilkan semua banner yang ada di desa kamu.', + cardTopRatio: 0.35, + arrowDirection: 'none', + }, + { + title: 'Buka Menu Aksi', + description: 'Ketuk salah satu banner untuk membuka menu aksi — kamu bisa melihat, mengedit, atau menghapus banner.', + cardTopRatio: 0.35, + arrowDirection: 'none', + }, + { + title: 'Tambah Banner', + description: 'Ketuk tombol tambah di pojok kanan atas untuk menambahkan banner baru.', + cardTopRatio: 0.15, + arrowDirection: 'up', + arrowOffset: 1.05, + }, +]; + +export const GUIDE_PROJECT_DETAIL: GuideStep[] = [ + { + title: 'Detail Kegiatan', + description: 'Halaman ini menampilkan informasi lengkap sebuah kegiatan, mulai dari progress, tugas, file, hingga anggota.', + cardTopRatio: 0.35, + arrowDirection: 'none', + }, + { + title: 'Progress Kegiatan', + description: 'Bar ini menunjukkan seberapa banyak tugas yang sudah diselesaikan dari total tugas yang ada.', + cardTopRatio: 0.28, + arrowDirection: 'up', + arrowOffset: 0.5, + }, + { + title: 'Daftar Tugas', + description: 'Semua tugas dalam kegiatan ini ditampilkan di sini. Ketuk tugas untuk melihat detail atau mengubah statusnya.', + cardTopRatio: 0.35, + arrowDirection: 'none', + }, + { + title: 'File & Tautan', + description: 'File dan tautan pendukung kegiatan tersedia di bagian bawah. Scroll ke bawah untuk mengaksesnya.', + cardTopRatio: 0.35, + arrowDirection: 'none', + }, +]; diff --git a/lib/useGuide.ts b/lib/useGuide.ts new file mode 100644 index 0000000..5d67382 --- /dev/null +++ b/lib/useGuide.ts @@ -0,0 +1,28 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useEffect, useState } from 'react'; + +const KEY_PREFIX = '@guide:'; + +export function useGuide(featureKey: string) { + const [visible, setVisible] = useState(false); + const [checked, setChecked] = useState(false); + + useEffect(() => { + AsyncStorage.getItem(KEY_PREFIX + featureKey).then((val) => { + if (!val) setVisible(true); + setChecked(true); + }); + }, [featureKey]); + + async function dismiss() { + setVisible(false); + await AsyncStorage.setItem(KEY_PREFIX + featureKey, 'done'); + } + + async function reset() { + await AsyncStorage.removeItem(KEY_PREFIX + featureKey); + setVisible(true); + } + + return { visible: checked && visible, dismiss, reset }; +}