From 84935e81881e802e6d118fb2420d10b93166178b Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Mon, 11 May 2026 15:19:21 +0800 Subject: [PATCH 1/5] feat: tambah fitur kalender umum village dengan indikator per jenis event - Tambah halaman /village-calendar dengan monthly grid dan agenda view - Tampilkan acara divisi (DivisionCalendarReminder) dan kegiatan (ProjectTask) se-village - Indikator dot dua warna pada kalender: ungu untuk acara divisi, biru-abu untuk kegiatan - Tambah endpoint apiGetVillageCalendarByDate dan apiGetVillageCalendarIndicator - Tambah menu Kalender di halaman /feature dengan grid layout flexWrap - Sesuaikan warna EventItem dengan TYPE_COLORS village-calendar - Pindahkan inline style ke Styles.ts sebagai class baru --- app/(application)/feature.tsx | 6 +- app/(application)/village-calendar/index.tsx | 294 +++++++++++++++++++ components/buttonFiturMenu.tsx | 4 +- components/calendar/itemDateCalendar.tsx | 27 +- components/eventItem.tsx | 8 +- constants/Styles.ts | 32 ++ lib/api.ts | 10 + 7 files changed, 362 insertions(+), 19 deletions(-) create mode 100644 app/(application)/village-calendar/index.tsx diff --git a/app/(application)/feature.tsx b/app/(application)/feature.tsx index 00b2311..16c9498 100644 --- a/app/(application)/feature.tsx +++ b/app/(application)/feature.tsx @@ -23,13 +23,12 @@ export default function Feature() { }} /> - + } text="Divisi" onPress={() => { router.push('/division?active=true') }} /> } text="Kegiatan" onPress={() => { router.push('/project?status=0') }} /> } text="Pengumuman" onPress={() => { router.push('/announcement') }} /> } text="Diskusi" onPress={() => { router.push('/discussion?active=true') }} /> - - + } text="Kalender" onPress={() => { router.push('/village-calendar') }} /> } text="Anggota" onPress={() => { router.push('/member') }} /> } text="Jabatan" onPress={() => { router.push('/position') }} /> { @@ -39,7 +38,6 @@ export default function Feature() { (entityUser.role == "supadmin" || entityUser.role == "developer") && <> } text="Lembaga Desa" onPress={() => { router.push('/group') }} /> - {/* } text="Tema" onPress={() => { }} /> */} } text="Banner" onPress={() => { router.push('/banner') }} /> } diff --git a/app/(application)/village-calendar/index.tsx b/app/(application)/village-calendar/index.tsx new file mode 100644 index 0000000..95aa9ea --- /dev/null +++ b/app/(application)/village-calendar/index.tsx @@ -0,0 +1,294 @@ +import AppHeader from "@/components/AppHeader"; +import ItemDateCalendar from "@/components/calendar/itemDateCalendar"; +import Skeleton from "@/components/skeleton"; +import Text from "@/components/Text"; +import Styles from "@/constants/Styles"; +import { apiGetVillageCalendarByDate, apiGetVillageCalendarIndicator } from "@/lib/api"; +import { useAuthSession } from "@/providers/AuthProvider"; +import { useTheme } from "@/providers/ThemeProvider"; +import { Feather } from "@expo/vector-icons"; +import { router, Stack } from "expo-router"; +import 'intl'; +import 'intl/locale-data/jsonp/id'; +import moment from "moment"; +import "moment/locale/id"; +import { useEffect, useState } from "react"; +import { Pressable, RefreshControl, SafeAreaView, ScrollView, View } from "react-native"; +import Datepicker, { + CalendarComponents, + CalendarDay +} from "react-native-ui-datepicker"; +moment.locale('id'); + +type EventItem = { + id: string; + type: 'calendar' | 'task'; + title: string; + desc: string; + dateStart: string; + dateEnd: string; + timeStart: string | null; + timeEnd: string | null; + divisionName: string | null; + projectName: string | null; + idDivision: string | null; + idRef: string; +}; + +const TYPE_COLORS = { + calendar: { + light: { bg: '#A9B5DF', stick: '#7886C7' }, + dark: { bg: '#2D2B5E', stick: '#7886C7' }, + }, + task: { + light: { bg: '#D6E6F2', stick: '#94B4C1' }, + dark: { bg: '#1C3347', stick: '#94B4C1' }, + }, +}; + +function VillageEventItem({ item, onPress }: { item: EventItem; onPress: () => void }) { + const { activeTheme, colors } = useTheme(); + const palette = TYPE_COLORS[item.type][activeTheme === 'dark' ? 'dark' : 'light']; + + return ( + + + + {item.timeStart ? ( + + {item.timeStart} - {item.timeEnd} + + ) : ( + + {moment(item.dateStart).format('D MMM YYYY')} – {moment(item.dateEnd).format('D MMM YYYY')} + + )} + + {item.title} + + + + + {item.type === 'calendar' ? 'Divisi' : 'Kegiatan'} + + + + {item.type === 'calendar' ? item.divisionName : item.projectName} + + + + + ); +} + +export default function VillageCalendar() { + const { colors, activeTheme } = useTheme(); + const { token, decryptToken } = useAuthSession(); + const [selected, setSelected] = useState(new Date()); + const [data, setData] = useState([]); + const [dataIndicator, setDataIndicator] = useState<{ calendar: string[], task: string[] }>({ calendar: [], task: [] }); + const [month, setMonth] = useState(new Date().getMonth()); + const [loading, setLoading] = useState(true); + const [loadingBtn, setLoadingBtn] = useState(false); + const [refreshing, setRefreshing] = useState(false); + + async function handleLoad(showLoading: boolean) { + try { + setLoading(showLoading); + const hasil = await decryptToken(String(token?.current)); + const response = await apiGetVillageCalendarByDate({ + user: hasil, + date: moment(selected).format("YYYY-MM-DD"), + }); + setData(response.data); + } catch (error) { + console.error(error); + } finally { + setLoading(false); + } + } + + async function handleLoadIndicator() { + try { + setLoadingBtn(true); + const newDate = new Date(selected?.getFullYear(), month, 1); + const hasil = await decryptToken(String(token?.current)); + const response = await apiGetVillageCalendarIndicator({ + user: hasil, + date: moment(newDate).format("YYYY-MM-DD"), + }); + setDataIndicator(response.data ?? { calendar: [], task: [] }); + } catch (error) { + console.error(error); + } finally { + setTimeout(() => setLoadingBtn(false), 500); + } + } + + useEffect(() => { + handleLoad(true); + }, [selected]); + + useEffect(() => { + handleLoadIndicator(); + }, [month]); + + const handleRefresh = async () => { + setRefreshing(true); + handleLoad(false); + handleLoadIndicator(); + await new Promise(resolve => setTimeout(resolve, 1500)); + setRefreshing(false); + }; + + function handlePressEvent(item: EventItem) { + if (item.type === 'calendar') { + router.push(`/division/${item.idDivision}/calendar/${item.id}` as any); + } else { + router.push(`/project/${item.idRef}` as any); + } + } + + const calendarEvents = data.filter(e => e.type === 'calendar'); + const taskEvents = data.filter(e => e.type === 'task'); + + const components: CalendarComponents = { + Day: (day: CalendarDay) => { + const today = moment(String(day.date)).format("YYYY-MM-DD"); + return ( + setSelected(new Date(today))} + /> + ); + }, + IconNext: ( + !loadingBtn && setMonth(month + 1)}> + + + ), + IconPrev: ( + !loadingBtn && setMonth(month - 1)}> + + + ), + }; + + return ( + + ( + router.back()} + /> + ), + }} + /> + + } + style={[Styles.h100]} + > + + {/* Calendar grid */} + + setMonth(m)} + styles={{ + selected: Styles.selectedDate, + month_label: { color: colors.text }, + month_selector_label: { color: colors.text }, + year_label: { color: colors.text }, + year_selector_label: { color: colors.text }, + day_label: { color: colors.text }, + time_label: { color: colors.text }, + weekday_label: { color: colors.text }, + }} + /> + + + {/* Legend */} + + + + Acara Divisi + + + + Kegiatan + + + + {/* Calendar events */} + + Acara Divisi + + {loading ? ( + <> + + + + ) : calendarEvents.length > 0 ? ( + calendarEvents.map((item, index) => ( + handlePressEvent(item)} + /> + )) + ) : ( + + Tidak ada acara + + )} + + + + {/* Task events */} + + Kegiatan + + {loading ? ( + <> + + + + ) : taskEvents.length > 0 ? ( + taskEvents.map((item, index) => ( + handlePressEvent(item)} + /> + )) + ) : ( + + Tidak ada kegiatan + + )} + + + + + + ); +} diff --git a/components/buttonFiturMenu.tsx b/components/buttonFiturMenu.tsx index d9c3533..ba224e6 100644 --- a/components/buttonFiturMenu.tsx +++ b/components/buttonFiturMenu.tsx @@ -14,11 +14,11 @@ export function ButtonFiturMenu({ onPress, icon, text }: Props) { const { colors } = useTheme(); return ( - + {icon} - {text} + {text} ) diff --git a/components/calendar/itemDateCalendar.tsx b/components/calendar/itemDateCalendar.tsx index a932783..a1354f4 100644 --- a/components/calendar/itemDateCalendar.tsx +++ b/components/calendar/itemDateCalendar.tsx @@ -6,19 +6,28 @@ import Text from "../Text"; type Props = { text: string; isSelected: boolean; - isSign: boolean; + isSign?: boolean; + isSignCalendar?: boolean; + isSignTask?: boolean; onPress?: () => void; } - -export default function ItemDateCalendar({ text, isSelected, isSign, onPress }: Props) { +export default function ItemDateCalendar({ text, isSelected, isSign, isSignCalendar, isSignTask, onPress }: Props) { const { colors } = useTheme() + + const showMulti = isSignCalendar !== undefined || isSignTask !== undefined; + return ( - <> - - {text} - - - + + {text} + {showMulti ? ( + + + + + ) : ( + + )} + ) } \ No newline at end of file diff --git a/components/eventItem.tsx b/components/eventItem.tsx index 10117cc..51af2f4 100644 --- a/components/eventItem.tsx +++ b/components/eventItem.tsx @@ -17,16 +17,16 @@ export default function EventItem({ category, title, user, jamAwal, jamAkhir, on const getBackgroundColor = (cat: 'purple' | 'orange') => { if (activeTheme === 'dark') { - return cat === 'orange' ? '#547792' : '#1D546D'; + return cat === 'purple' ? '#2D2B5E' : '#1C3347'; } - return cat === 'orange' ? '#D6E6F2' : '#A9B5DF'; + return cat === 'purple' ? '#A9B5DF' : '#D6E6F2'; }; const getStickColor = (cat: 'purple' | 'orange') => { if (activeTheme === 'dark') { - return cat === 'orange' ? '#94B4C1' : '#5F9598'; + return cat === 'purple' ? '#7886C7' : '#94B4C1'; } - return cat === 'orange' ? '#F5F5F5' : '#7886C7'; + return cat === 'purple' ? '#7886C7' : '#94B4C1'; }; return ( diff --git a/constants/Styles.ts b/constants/Styles.ts index e3b2462..b49195a 100644 --- a/constants/Styles.ts +++ b/constants/Styles.ts @@ -1169,6 +1169,38 @@ const Styles = StyleSheet.create({ fontSize: 10, fontStyle: 'italic', }, + + // village calendar & itemDateCalendar + calendarDotRow: { + flexDirection: 'row', + gap: 2, + height: 6, + marginTop: 1, + }, + calendarDot: { + width: 5, + height: 5, + borderRadius: 3, + }, + villageEventLegendRow: { + marginTop: 10, + marginBottom: 4, + gap: 16, + }, + villageEventLegendItem: { + gap: 6, + }, + villageEventLegendDot: { + width: 10, + height: 10, + borderRadius: 5, + }, + villageEventBadge: { + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + marginRight: 6, + }, }) export default Styles; \ No newline at end of file diff --git a/lib/api.ts b/lib/api.ts index cf3e82a..a2a84f0 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -619,6 +619,16 @@ export const apiGetCalendarHistory = async ({ user, search, division, page }: { return response.data; }; +export const apiGetVillageCalendarByDate = async ({ user, date }: { user: string, date: string }) => { + const response = await api.get(`mobile/village-calendar?user=${user}&date=${date}`); + return response.data; +}; + +export const apiGetVillageCalendarIndicator = async ({ user, date }: { user: string, date: string }) => { + const response = await api.get(`mobile/village-calendar/indicator?user=${user}&date=${date}`); + return response.data; +}; + export const apiCreateCalendar = async ({ data }: { data: { idDivision: string, title: string, desc: string, timeStart: string, timeEnd: string, dateStart: string, repeatEventTyper: string, repeatValue: string, linkMeet: string, member: any[], user: string } }) => { const response = await api.post(`/mobile/calendar`, data) return response.data; -- 2.49.1 From 7341f378dd11d2ef1fdeafe52d3678958e9dc0ac Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Mon, 11 May 2026 16:34:46 +0800 Subject: [PATCH 2/5] 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 --- app/(application)/announcement/index.tsx | 5 + app/(application)/banner/index.tsx | 6 +- app/(application)/discussion/index.tsx | 5 + .../[id]/(fitur-division)/calendar/index.tsx | 5 + .../(fitur-division)/discussion/index.tsx | 5 + .../[id]/(fitur-division)/document/index.tsx | 5 + .../(fitur-division)/task/[detail]/index.tsx | 5 + app/(application)/group/index.tsx | 5 + app/(application)/member/index.tsx | 5 + app/(application)/position/index.tsx | 5 + app/(application)/project/[id]/index.tsx | 5 + app/(application)/village-calendar/index.tsx | 10 + components/GuideOverlay.tsx | 159 +++++++++++++ constants/Styles.ts | 70 ++++++ lib/guideSteps.ts | 217 ++++++++++++++++++ lib/useGuide.ts | 28 +++ 16 files changed, 539 insertions(+), 1 deletion(-) create mode 100644 components/GuideOverlay.tsx create mode 100644 lib/guideSteps.ts create mode 100644 lib/useGuide.ts 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 }; +} -- 2.49.1 From af2048b4cdacb059ed64c71ed11102b4d582cd38 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Mon, 11 May 2026 17:11:05 +0800 Subject: [PATCH 3/5] fix: ganti penyimpanan token dari AsyncStorage ke SecureStore Token autentikasi kini disimpan di Keychain (iOS) / Keystore (Android) sehingga otomatis terhapus saat uninstall dan tidak bisa dipulihkan lewat Google Backup. @notification_permission tetap di AsyncStorage karena bukan data sensitif. --- bun.lock | 3 +++ package.json | 1 + providers/AuthProvider.tsx | 9 +++++---- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/bun.lock b/bun.lock index 952f9ff..613e278 100644 --- a/bun.lock +++ b/bun.lock @@ -46,6 +46,7 @@ "expo-modules-core": "^2.5.0", "expo-notifications": "~0.31.3", "expo-router": "~5.1.4", + "expo-secure-store": "~14.2.4", "expo-sharing": "^13.1.5", "expo-splash-screen": "~0.30.8", "expo-status-bar": "~2.2.3", @@ -1103,6 +1104,8 @@ "expo-router": ["expo-router@5.1.11", "", { "dependencies": { "@expo/metro-runtime": "5.0.5", "@expo/schema-utils": "^0.1.0", "@expo/server": "^0.6.3", "@radix-ui/react-slot": "1.2.0", "@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/native": "^7.1.6", "@react-navigation/native-stack": "^7.3.10", "client-only": "^0.0.1", "invariant": "^2.2.4", "react-fast-compare": "^3.2.2", "react-native-is-edge-to-edge": "^1.1.6", "semver": "~7.6.3", "server-only": "^0.0.1", "shallowequal": "^1.1.0" }, "peerDependencies": { "@react-navigation/drawer": "^7.3.9", "expo": "*", "expo-constants": "*", "expo-linking": "*", "react-native-reanimated": "*", "react-native-safe-area-context": "*", "react-native-screens": "*", "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" }, "optionalPeers": ["@react-navigation/drawer", "react-native-reanimated", "react-server-dom-webpack"] }, "sha512-6YQGqQM2rviVSiU6++hrJDPMByHZ7Oiux4XmgoSaGdaHku5QOn9911f2puEUZh2H9ALKBipw5v3ZkrECBd6Zbw=="], + "expo-secure-store": ["expo-secure-store@14.2.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-ePaz4fnTitJJZjAiybaVYGfLWWyaEtepZC+vs9ZBMhQMfG5HUotIcVsDaSo3FnwpHmgwsLVPY2qFeryI6AtULw=="], + "expo-sharing": ["expo-sharing@13.1.5", "", { "peerDependencies": { "expo": "*" } }, "sha512-X/5sAEiWXL2kdoGE3NO5KmbfcmaCWuWVZXHu8OQef7Yig4ZgHFkGD11HKJ5KqDrDg+SRZe4ISd6MxE7vGUgm4w=="], "expo-splash-screen": ["expo-splash-screen@0.30.10", "", { "dependencies": { "@expo/prebuild-config": "^9.0.10" }, "peerDependencies": { "expo": "*" } }, "sha512-Tt9va/sLENQDQYeOQ6cdLdGvTZ644KR3YG9aRlnpcs2/beYjOX1LHT510EGzVN9ljUTg+1ebEo5GGt2arYtPjw=="], diff --git a/package.json b/package.json index bb9c889..94f34bf 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "expo-modules-core": "^2.5.0", "expo-notifications": "~0.31.3", "expo-router": "~5.1.4", + "expo-secure-store": "~14.2.4", "expo-sharing": "^13.1.5", "expo-splash-screen": "~0.30.8", "expo-status-bar": "~2.2.3", diff --git a/providers/AuthProvider.tsx b/providers/AuthProvider.tsx index a1793cd..94e25d2 100644 --- a/providers/AuthProvider.tsx +++ b/providers/AuthProvider.tsx @@ -2,6 +2,7 @@ import { ConstEnv } from '@/constants/ConstEnv'; import { apiRegisteredToken, apiUnregisteredToken } from '@/lib/api'; import { getToken } from '@/lib/useNotification'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as SecureStore from 'expo-secure-store'; import { useQueryClient } from '@tanstack/react-query'; import CryptoES from "crypto-es"; import { router } from "expo-router"; @@ -35,7 +36,7 @@ export default function AuthProvider({ children }: { children: ReactNode }): Rea useEffect(() => { (async (): Promise => { - const token = await AsyncStorage.getItem('@token'); + const token = await SecureStore.getItemAsync('@token'); tokenRef.current = token || ''; setIsLoading(false); })() @@ -62,14 +63,14 @@ export default function AuthProvider({ children }: { children: ReactNode }): Rea } catch (error) { console.error(error) } finally { - await AsyncStorage.setItem('@token', token); + await SecureStore.setItemAsync('@token', token); tokenRef.current = token; router.replace('/home') return true } } else { await apiRegisteredToken({ user: hasil, token: "" }) - await AsyncStorage.setItem('@token', token); + await SecureStore.setItemAsync('@token', token); tokenRef.current = token; router.replace('/home') } @@ -87,7 +88,7 @@ export default function AuthProvider({ children }: { children: ReactNode }): Rea } catch (error) { console.error(error) } finally { - await AsyncStorage.setItem('@token', ''); + await SecureStore.deleteItemAsync('@token'); tokenRef.current = null; queryClient.clear(); router.replace('/'); -- 2.49.1 From 906a6195930fb3bdaf0d4f9f2b786120b5eec5c2 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Mon, 11 May 2026 17:15:52 +0800 Subject: [PATCH 4/5] revert: kembalikan penyimpanan token ke AsyncStorage --- providers/AuthProvider.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/providers/AuthProvider.tsx b/providers/AuthProvider.tsx index 94e25d2..a1793cd 100644 --- a/providers/AuthProvider.tsx +++ b/providers/AuthProvider.tsx @@ -2,7 +2,6 @@ import { ConstEnv } from '@/constants/ConstEnv'; import { apiRegisteredToken, apiUnregisteredToken } from '@/lib/api'; import { getToken } from '@/lib/useNotification'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import * as SecureStore from 'expo-secure-store'; import { useQueryClient } from '@tanstack/react-query'; import CryptoES from "crypto-es"; import { router } from "expo-router"; @@ -36,7 +35,7 @@ export default function AuthProvider({ children }: { children: ReactNode }): Rea useEffect(() => { (async (): Promise => { - const token = await SecureStore.getItemAsync('@token'); + const token = await AsyncStorage.getItem('@token'); tokenRef.current = token || ''; setIsLoading(false); })() @@ -63,14 +62,14 @@ export default function AuthProvider({ children }: { children: ReactNode }): Rea } catch (error) { console.error(error) } finally { - await SecureStore.setItemAsync('@token', token); + await AsyncStorage.setItem('@token', token); tokenRef.current = token; router.replace('/home') return true } } else { await apiRegisteredToken({ user: hasil, token: "" }) - await SecureStore.setItemAsync('@token', token); + await AsyncStorage.setItem('@token', token); tokenRef.current = token; router.replace('/home') } @@ -88,7 +87,7 @@ export default function AuthProvider({ children }: { children: ReactNode }): Rea } catch (error) { console.error(error) } finally { - await SecureStore.deleteItemAsync('@token'); + await AsyncStorage.setItem('@token', ''); tokenRef.current = null; queryClient.clear(); router.replace('/'); -- 2.49.1 From 18f548ed5cfeb469d72a2660ffc2eefa26144b43 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Mon, 11 May 2026 17:18:47 +0800 Subject: [PATCH 5/5] chore: update Podfile.lock --- ios/Podfile.lock | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index c1a059c..000d729 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -318,6 +318,8 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga + - ExpoSecureStore (14.2.4): + - ExpoModulesCore - ExpoSharing (13.1.5): - ExpoModulesCore - ExpoSplashScreen (0.30.10): @@ -2247,6 +2249,7 @@ DEPENDENCIES: - ExpoLinking (from `../node_modules/expo-linking/ios`) - ExpoMediaLibrary (from `../node_modules/expo-media-library/ios`) - ExpoModulesCore (from `../node_modules/expo-modules-core`) + - ExpoSecureStore (from `../node_modules/expo-secure-store/ios`) - ExpoSharing (from `../node_modules/expo-sharing/ios`) - ExpoSplashScreen (from `../node_modules/expo-splash-screen/ios`) - ExpoSymbols (from `../node_modules/expo-symbols/ios`) @@ -2420,6 +2423,8 @@ EXTERNAL SOURCES: :path: "../node_modules/expo-media-library/ios" ExpoModulesCore: :path: "../node_modules/expo-modules-core" + ExpoSecureStore: + :path: "../node_modules/expo-secure-store/ios" ExpoSharing: :path: "../node_modules/expo-sharing/ios" ExpoSplashScreen: @@ -2630,6 +2635,7 @@ SPEC CHECKSUMS: ExpoLinking: d5c183998ca6ada66ff45e407e0f965b398a8902 ExpoMediaLibrary: 0daf5e811e00daa47690f5da2989e71ff7de56e0 ExpoModulesCore: 272bc6c06ddd9c4bee2048acc57891cab3700627 + ExpoSecureStore: 3f1b632d6d40bcc62b4983ef9199cd079592a50a ExpoSharing: b0377be82430d07398c6a4cd60b5a15696accbd3 ExpoSplashScreen: 1c22c5d37647106e42d4ae1582bb6d0dda3b2385 ExpoSymbols: c5612a90fb9179cdaebcd19bea9d8c69e5d3b859 -- 2.49.1