diff --git a/app/(application)/(user)/portofolio/[id]/edit-logo.tsx b/app/(application)/(user)/portofolio/[id]/edit-logo.tsx index 9845fb4..b52a81a 100644 --- a/app/(application)/(user)/portofolio/[id]/edit-logo.tsx +++ b/app/(application)/(user)/portofolio/[id]/edit-logo.tsx @@ -3,7 +3,7 @@ import { BoxButtonOnFooter, ButtonCenteredOnly, ButtonCustom, - ViewWrapper + OS_Wrapper } from "@/components"; import API_STRORAGE from "@/constants/base-url-api-strorage"; import DIRECTORY_ID from "@/constants/directory-id"; @@ -126,7 +126,7 @@ export default function PortofolioEditLogo() { return ( <> - + Upload - + ); } diff --git a/app/(application)/(user)/portofolio/[id]/edit-social-media.tsx b/app/(application)/(user)/portofolio/[id]/edit-social-media.tsx index 1770c3f..dfbb691 100644 --- a/app/(application)/(user)/portofolio/[id]/edit-social-media.tsx +++ b/app/(application)/(user)/portofolio/[id]/edit-social-media.tsx @@ -1,8 +1,8 @@ import { BoxButtonOnFooter, ButtonCustom, + OS_Wrapper, TextInputCustom, - ViewWrapper, } from "@/components"; import { apiGetOnePortofolio, @@ -91,7 +91,11 @@ export default function PortofolioEditSocialMedia() { return ( <> - + setData({ ...data, tiktok: value })} @@ -122,7 +126,7 @@ export default function PortofolioEditSocialMedia() { label="Youtube" placeholder="Masukkan youtube" /> - + ); } diff --git a/app/(application)/(user)/portofolio/[id]/edit.tsx b/app/(application)/(user)/portofolio/[id]/edit.tsx index b1dbfad..eeeeb4c 100644 --- a/app/(application)/(user)/portofolio/[id]/edit.tsx +++ b/app/(application)/(user)/portofolio/[id]/edit.tsx @@ -4,7 +4,7 @@ import { BoxButtonOnFooter, ButtonCustom, CenterCustom, - NewWrapper, + OS_Wrapper, PhoneInputCustom, SelectCustom, Spacing, @@ -16,7 +16,11 @@ import { import ListSkeletonComponent from "@/components/_ShareComponent/ListSkeletonComponent"; import { MainColor } from "@/constants/color-palet"; import { ICON_SIZE_XLARGE } from "@/constants/constans-value"; -import { DEFAULT_COUNTRY, type CountryData, COUNTRIES } from "@/constants/countries"; +import { + DEFAULT_COUNTRY, + type CountryData, + COUNTRIES, +} from "@/constants/countries"; import { apiMasterBidangBisnis, apiMasterSubBidangBisnis, @@ -61,7 +65,8 @@ export default function PortofolioEdit() { const [isLoading, setIsLoading] = useState(false); const [data, setData] = useState({}); const [phoneNumber, setPhoneNumber] = useState(""); - const [selectedCountry, setSelectedCountry] = useState(DEFAULT_COUNTRY); + const [selectedCountry, setSelectedCountry] = + useState(DEFAULT_COUNTRY); const [bidangBisnis, setBidangBisnis] = useState< IMasterBidangBisnis[] | null >(null); @@ -75,38 +80,38 @@ export default function PortofolioEdit() { function handlePhoneChange(phone: string) { setPhoneNumber(phone); - + // Format phone number for API const callingCode = selectedCountry.callingCode; let fixNumber = phone.replace(/\s+/g, "").replace(/^0+/, ""); - + // Remove country code if already present if (fixNumber.startsWith(callingCode)) { fixNumber = fixNumber.substring(callingCode.length); } - + // Remove leading zero fixNumber = fixNumber.replace(/^0+/, ""); - + const realNumber = callingCode + fixNumber; setData({ ...data, tlpn: realNumber }); } function handleCountryChange(country: CountryData) { setSelectedCountry(country); - + // Re-format with new country code const callingCode = country.callingCode; let fixNumber = phoneNumber.replace(/\s+/g, "").replace(/^0+/, ""); - + // Remove country code if already present if (fixNumber.startsWith(callingCode)) { fixNumber = fixNumber.substring(callingCode.length); } - + // Remove leading zero fixNumber = fixNumber.replace(/^0+/, ""); - + const realNumber = callingCode + fixNumber; setData({ ...data, tlpn: realNumber }); } @@ -157,7 +162,7 @@ export default function PortofolioEdit() { const fullNumber = response.data.tlpn; let displayNumber = fullNumber; let detectedCountry = DEFAULT_COUNTRY; - + // Try to detect country from calling code for (const country of COUNTRIES) { if (fullNumber.startsWith(country.callingCode)) { @@ -166,12 +171,12 @@ export default function PortofolioEdit() { break; } } - + setSelectedCountry(detectedCountry); - + // Remove leading zero if present displayNumber = displayNumber.replace(/^0+/, ""); - + setPhoneNumber(displayNumber); setData({ ...response.data, tlpn: displayNumber }); @@ -363,161 +368,159 @@ export default function PortofolioEdit() { ); - if (!bidangBisnis || !subBidangBisnis) { - return ( - <> - - - - - ); - } - return ( <> - - - - setData({ ...data, namaBisnis: value }) - } - /> - - ({ - label: item.name, - value: item.id, - }))} - value={data.masterBidangBisnisId} - onChange={(value: any) => { - handleBidangBisnisChange(value); - }} - /> - - {listSubBidangSelected.map((item, index) => { - // Filter data untuk select sub bidang, menghilangkan yang sudah dipilih kecuali untuk item ini sendiri - const selectedIds = listSubBidangSelected - .filter((_, i) => i !== index) - .map((s) => s.MasterSubBidangBisnis?.id) - .filter((id) => id); // Filter hanya yang memiliki id (tidak kosong) - - const availableSubBidangOptions = (selectedSubBidang || []) - .filter((sub: any) => { - // Tampilkan jika ini adalah opsi yang dipilih saat ini atau belum dipilih di sub bidang lainnya - - return ( - sub.id === item.MasterSubBidangBisnis?.id || - !selectedIds.includes(sub.id) - ); - }) - .map((sub: any) => ({ - value: sub.id, - label: sub.name, - })); - - return ( - { - handleSubBidangChange(value, index); - }} - /> - ); - })} - - - - { - handleAddSubBidang(); - }} - icon={ - - } - size="xl" - /> - { - handleRemoveSubBidang(listSubBidangSelected.length - 1); - }} - icon={ - - } - size="xl" - /> - - - - - - - - Nomor Telepon - - * - - - + {!bidangBisnis || !subBidangBisnis ? ( + + ) : ( + + + setData({ ...data, namaBisnis: value }) + } /> - - - - setData({ ...data, alamatKantor: value }) - } - /> + ({ + label: item.name, + value: item.id, + }))} + value={data.masterBidangBisnisId} + onChange={(value: any) => { + handleBidangBisnisChange(value); + }} + /> - - setData({ ...data, deskripsi: value }) - } - autosize - minRows={2} - maxRows={5} - required - showCount - maxLength={1000} - /> - - - + {listSubBidangSelected.map((item, index) => { + // Filter data untuk select sub bidang, menghilangkan yang sudah dipilih kecuali untuk item ini sendiri + const selectedIds = listSubBidangSelected + .filter((_, i) => i !== index) + .map((s) => s.MasterSubBidangBisnis?.id) + .filter((id) => id); // Filter hanya yang memiliki id (tidak kosong) + + const availableSubBidangOptions = (selectedSubBidang || []) + .filter((sub: any) => { + // Tampilkan jika ini adalah opsi yang dipilih saat ini atau belum dipilih di sub bidang lainnya + + return ( + sub.id === item.MasterSubBidangBisnis?.id || + !selectedIds.includes(sub.id) + ); + }) + .map((sub: any) => ({ + value: sub.id, + label: sub.name, + })); + + return ( + { + handleSubBidangChange(value, index); + }} + /> + ); + })} + + + + { + handleAddSubBidang(); + }} + icon={ + + } + size="xl" + /> + { + handleRemoveSubBidang(listSubBidangSelected.length - 1); + }} + icon={ + + } + size="xl" + /> + + + + + + + + Nomor Telepon + + * + + + + + + + + setData({ ...data, alamatKantor: value }) + } + /> + + + setData({ ...data, deskripsi: value }) + } + autosize + minRows={2} + maxRows={5} + required + showCount + maxLength={1000} + /> + + + )} + ); } diff --git a/app/(application)/(user)/portofolio/[id]/index.tsx b/app/(application)/(user)/portofolio/[id]/index.tsx index ed2c2d3..51949ec 100644 --- a/app/(application)/(user)/portofolio/[id]/index.tsx +++ b/app/(application)/(user)/portofolio/[id]/index.tsx @@ -4,6 +4,7 @@ import { DrawerCustom, DummyLandscapeImage, LoaderCustom, + OS_Wrapper, Spacing, StackCustom, TextCustom, @@ -12,7 +13,6 @@ import AppHeader from "@/components/_ShareComponent/AppHeader"; import LeftButtonCustom from "@/components/Button/BackButton"; import GridTwoView from "@/components/_ShareComponent/GridTwoView"; import CustomSkeleton from "@/components/_ShareComponent/SkeletonCustom"; -import ViewWrapper from "@/components/_ShareComponent/ViewWrapper"; import { MainColor } from "@/constants/color-palet"; import { ICON_SIZE_SMALL } from "@/constants/constans-value"; import { useAuth } from "@/hooks/use-auth"; @@ -92,7 +92,7 @@ export default function Portofolio() { ), }} /> - + {!data || !profileId ? ( @@ -125,7 +125,7 @@ export default function Portofolio() { )} - + {/* Drawer Komponen Eksternal */} + {headerComponent && ( {headerComponent} )} @@ -208,7 +210,7 @@ export function AndroidWrapper(props: AndroidWrapperProps) { showsVerticalScrollIndicator={false} > - {renderContainer(staticProps.children)} + {renderContainer(childrenWithFocus)} @@ -239,7 +241,7 @@ export function AndroidWrapper(props: AndroidWrapperProps) { {floatingButton && ( {floatingButton} )} - + ); } diff --git a/hooks/useKeyboardForm.ts b/hooks/useKeyboardForm.ts index 115f988..7830ff1 100644 --- a/hooks/useKeyboardForm.ts +++ b/hooks/useKeyboardForm.ts @@ -1,12 +1,25 @@ // useKeyboardForm.ts - Hook untuk keyboard handling pada form -import { Keyboard, ScrollView, Dimensions } from "react-native"; -import { useState, useEffect, useRef } from "react"; -import React from "react"; +import { Keyboard, ScrollView, Dimensions, findNodeHandle, UIManager } from "react-native"; +import { useState, useEffect, useRef, useCallback } from "react"; export function useKeyboardForm(scrollOffset = 100) { const scrollViewRef = useRef(null); const [keyboardHeight, setKeyboardHeight] = useState(0); const currentScrollY = useRef(0); + const inputPageY = useRef(0); + const screenHeight = Dimensions.get('window').height; + + // Fungsi untuk mengukur posisi absolut input + const handleInputFocus = useCallback((target: any) => { + const nodeHandle = findNodeHandle(target); + if (nodeHandle) { + UIManager.measure(nodeHandle, (x, y, width, height, pageX, pageY) => { + if (pageY !== undefined && pageY !== null) { + inputPageY.current = pageY; + } + }); + } + }, []); // Listen to keyboard events useEffect(() => { @@ -16,20 +29,34 @@ export function useKeyboardForm(scrollOffset = 100) { const kbHeight = e.endCoordinates.height; setKeyboardHeight(kbHeight); - // Simple: scroll by keyboard height saat keyboard muncul + // Conditional scroll: hanya scroll jika input tertutup keyboard if (scrollViewRef.current) { - const targetY = currentScrollY.current + kbHeight - scrollOffset; - scrollViewRef.current.scrollTo({ - y: Math.max(0, targetY), - animated: true, - }); + const touchAbsoluteY = inputPageY.current; + + // Posisi Y teratas keyboard (dari atas layar) + const keyboardTopY = screenHeight - kbHeight; + + // Jika input ADA DI BAWAH keyboard (tertutup) + if (touchAbsoluteY > keyboardTopY) { + // Hitung berapa harus scroll agar input terlihat di atas keyboard + const scrollBy = touchAbsoluteY - keyboardTopY + scrollOffset; + const targetY = currentScrollY.current + scrollBy; + + scrollViewRef.current.scrollTo({ + y: Math.max(0, targetY), + animated: true, + }); + } + // Jika input SUDAH TERLIHAT (di atas keyboard), JANGAN SCROLL } } ); + const keyboardDidHideListener = Keyboard.addListener( 'keyboardDidHide', () => { setKeyboardHeight(0); + inputPageY.current = 0; } ); @@ -37,35 +64,74 @@ export function useKeyboardForm(scrollOffset = 100) { keyboardDidShowListener.remove(); keyboardDidHideListener.remove(); }; - }, [scrollOffset]); + }, [scrollOffset, screenHeight]); // Track scroll position const handleScroll = (event: any) => { currentScrollY.current = event.nativeEvent.contentOffset.y; }; - // Dummy handlers (for API compatibility) - const createFocusHandler = () => { - return () => {}; - }; - - const registerInputFocus = () => {}; - return { scrollViewRef, keyboardHeight, - registerInputFocus, - createFocusHandler, + handleInputFocus, handleScroll, }; } /** - * Dummy helper (no-op, for API compatibility) + * Helper untuk inject onFocus handler ke semua TextInput/TextArea children + * Menggunakan UI.measure untuk mendapatkan posisi absolut input secara akurat */ export function cloneChildrenWithFocusHandler( children: React.ReactNode, - _focusHandler: (inputRef: any) => void + focusHandler: (target: any) => void ): React.ReactNode { - return children; -} + if (!children) return children; + const React = require("react"); + + return React.Children.map(children, (child: any) => { + if (!React.isValidElement(child)) return child; + + const childType = child.type; + const childProps = child.props as Record || {}; + + // Check if it's a text input component + let isTextInput = false; + + if (typeof childType === 'string') { + isTextInput = childType.toLowerCase().includes('textinput'); + } else if (childType) { + isTextInput = + (childType as any).displayName?.includes('TextInput') || + (childType as any).name?.includes('TextInput') || + (childType as any).displayName?.includes('TextArea') || + (childType as any).name?.includes('TextArea') || + (childType as any).displayName?.includes('PhoneInput') || + (childType as any).name?.includes('PhoneInput') || + (childType as any).displayName?.includes('Select') || + (childType as any).name?.includes('Select'); + } + + if (isTextInput) { + const existingOnFocus = childProps.onFocus; + return React.cloneElement(child, { + ...childProps, + onFocus: (e: any) => { + existingOnFocus?.(e); + focusHandler(e.target); + }, + } as any); + } + + // Recursively clone nested children + if (childProps.children) { + return React.cloneElement(child, { + ...childProps, + children: cloneChildrenWithFocusHandler(childProps.children, focusHandler), + } as any); + } + + return child; + }); +} \ No newline at end of file diff --git a/screens/Maps/ScreenMapsCreate.tsx b/screens/Maps/ScreenMapsCreate.tsx index 33f5dfb..ea41df4 100644 --- a/screens/Maps/ScreenMapsCreate.tsx +++ b/screens/Maps/ScreenMapsCreate.tsx @@ -1,4 +1,3 @@ -import NewWrapper from "@/components/_ShareComponent/NewWrapper"; import { BoxButtonOnFooter, ButtonCustom, @@ -8,6 +7,7 @@ import { TextInputCustom, LandscapeFrameUploaded, ButtonCenteredOnly, + OS_Wrapper, } from "@/components"; import { MapSelectedPlatform } from "@/components/Map/MapSelectedPlatform"; import DIRECTORY_ID from "@/constants/directory-id"; @@ -142,10 +142,14 @@ export function Maps_ScreenMapsCreate() { ); /** - * Render screen dengan NewWrapper + * Render screen dengan OS_Wrapper */ return ( - + @@ -179,7 +183,7 @@ export function Maps_ScreenMapsCreate() { - + ); } diff --git a/screens/Maps/ScreenMapsEdit.tsx b/screens/Maps/ScreenMapsEdit.tsx index deee80f..b28b4ed 100644 --- a/screens/Maps/ScreenMapsEdit.tsx +++ b/screens/Maps/ScreenMapsEdit.tsx @@ -7,7 +7,7 @@ import { LandscapeFrameUploaded, Spacing, TextInputCustom, - ViewWrapper, + OS_Wrapper, } from "@/components"; import CustomSkeleton from "@/components/_ShareComponent/SkeletonCustom"; import { MapSelectedPlatform } from "@/components/Map/MapSelectedPlatform"; @@ -166,7 +166,11 @@ export function Maps_ScreenMapsEdit() { : defaultRegion; return ( - + {/* - + ); } diff --git a/screens/Portofolio/ScreenPortofolioCreate.tsx b/screens/Portofolio/ScreenPortofolioCreate.tsx index 6547b7b..5b7f680 100644 --- a/screens/Portofolio/ScreenPortofolioCreate.tsx +++ b/screens/Portofolio/ScreenPortofolioCreate.tsx @@ -4,7 +4,7 @@ import { ButtonCenteredOnly, CenterCustom, InformationBox, - NewWrapper, + OS_Wrapper, PhoneInputCustom, SelectCustom, Spacing, @@ -142,7 +142,9 @@ export function ScreenPortofolioCreate() { }; return ( - - + ); } diff --git a/screens/Portofolio/ViewListPortofolio.tsx b/screens/Portofolio/ViewListPortofolio.tsx index 2049feb..44bd907 100644 --- a/screens/Portofolio/ViewListPortofolio.tsx +++ b/screens/Portofolio/ViewListPortofolio.tsx @@ -1,4 +1,4 @@ -import { NewWrapper, TextCustom } from "@/components"; +import { OS_Wrapper, TextCustom } from "@/components"; import CustomSkeleton from "@/components/_ShareComponent/SkeletonCustom"; import { MainColor } from "@/constants/color-palet"; import { usePagination } from "@/hooks/use-pagination"; @@ -52,7 +52,7 @@ export default function ViewListPortofolio() { }); return ( - - } + refreshControl={} /> ``` @@ -209,7 +205,7 @@ Setiap screen yang sudah di-migrate, test: } footerComponent={} - contentPadding={PADDING_INLINE} + refreshControl={} > @@ -220,60 +216,7 @@ Setiap screen yang sudah di-migrate, test: - Submit - - } -> - - -``` - -### Migration Pattern: - -```tsx -// OLD -import NewWrapper from "@/components/_ShareComponent/NewWrapper"; - - - -// NEW -import { OS_Wrapper } from "@/components"; -import { PADDING_INLINE } from "@/constants/constans-value"; - - -``` - -```tsx -// OLD (Form with keyboard handling) -import { NewWrapper_V2 } from "@/components/_ShareComponent/NewWrapper_V2"; - - - - - -// NEW (Unified API) -import { OS_Wrapper } from "@/components"; - -} > @@ -287,6 +230,9 @@ import { OS_Wrapper } from "@/components"; ### Issue: Keyboard menutupi input di Android **Solution**: Pastikan pakai `OS_Wrapper` dengan `enableKeyboardHandling={true}` dan `contentPaddingBottom={250}` untuk form screens. +### Issue: Pull-to-refresh tidak berfungsi di static mode +**Solution**: Sudah diperbaiki! `refreshControl` sekarang di-pass ke ScrollView di AndroidWrapper. + ### Issue: Footer terlalu jauh dari bottom **Solution**: Kurangi `contentPaddingBottom` (default: 100 untuk list). Untuk form screens tetap 250. @@ -298,10 +244,15 @@ import { OS_Wrapper } from "@/components"; | Phase | Total Files | Migrated | Testing | Status | |-------|-------------|----------|---------|--------| | Phase 1 (Job) | 9 | 9 | ✅ Complete | ✅ Complete | -| Phase 2 (User) | TBD | 0 | 0 | ⏳ Pending | -| Phase 3 (Admin) | TBD | 0 | 0 | ⏳ Pending | -| Phase 4 (Other) | TBD | 0 | 0 | ⏳ Pending | -| **Total** | **9+** | **9** | **9** | **100% (Phase 1)** | +| Phase 2 (Profile + Others) | 10 | 10 | ⏳ Pending | ✅ Complete | +| Phase 3 (Portfolio) | 6 | 6 | ⏳ Pending | ✅ Complete | +| Phase 4 (Maps) | 2 | 2 | ⏳ Pending | ✅ Complete | +| Phase 5 (Event) | TBD | 0 | 0 | ⏳ Pending | +| Phase 6 (Voting) | TBD | 0 | 0 | ⏳ Pending | +| Phase 7 (Forum) | TBD | 0 | 0 | ⏳ Pending | +| Phase 8 (Donation) | TBD | 0 | 0 | ⏳ Pending | +| Phase 9 (Other) | TBD | 0 | 0 | ⏳ Pending | +| **Total** | **27+** | **27** | **9** | **Phase 1-4 Complete** | ## 🔄 Rollback Plan @@ -316,5 +267,5 @@ Jika ada issue yang tidak bisa di-fix dalam 1 jam: **Co-authored-by**: Qwen-Coder **Created**: 2026-04-06 **Last Updated**: 2026-04-08 -**Status**: Phase 1 (Job Screens) Complete ✅ -**Next**: Phase 2 - Other User Screens (Profile, Forum, Portfolio) \ No newline at end of file +**Status**: Phase 1-4 Complete ✅ (27 files migrated) +**Next**: Phase 5 - Event Management Screens