feat: Migrate Job screens to NewWrapper_V2 with keyboard handling
- Migrate ScreenJobCreate.tsx to NewWrapper_V2
- Migrate ScreenJobEdit.tsx to NewWrapper_V2
- Add NewWrapper_V2 component with auto-scroll keyboard handling
- Add useKeyboardForm hook for keyboard management
- Add FormWrapper component for forms
- Create ScreenJobEdit.tsx from edit route (separation of concerns)
- Add documentation for keyboard implementation
- Add TASK-004 migration plan
- Fix: Footer width 100% with safe positioning
- Fix: Content padding bottom 80px for navigation bar
- Fix: Auto-scroll to focused input
- Fix: No white area when keyboard close
- Fix: Footer not raised after keyboard close
Phase 1 completed: Job screens migrated
### No Issue
This commit is contained in:
@@ -1,198 +1,11 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import {
|
||||
BaseBox,
|
||||
ButtonCenteredOnly,
|
||||
ButtonCustom,
|
||||
DummyLandscapeImage,
|
||||
InformationBox,
|
||||
LandscapeFrameUploaded,
|
||||
LoaderCustom,
|
||||
Spacing,
|
||||
StackCustom,
|
||||
TextAreaCustom,
|
||||
TextInputCustom,
|
||||
ViewWrapper,
|
||||
} from "@/components";
|
||||
import DIRECTORY_ID from "@/constants/directory-id";
|
||||
import { apiJobGetOne, apiJobUpdateData } from "@/service/api-client/api-job";
|
||||
import {
|
||||
deleteFileService,
|
||||
uploadFileService,
|
||||
} from "@/service/upload-service";
|
||||
import pickImage from "@/utils/pickImage";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import Toast from "react-native-toast-message";
|
||||
import { Job_ScreenEdit } from "@/screens/Job/ScreenJobEdit";
|
||||
import { Job_ScreenEdit2 } from "@/screens/Job/ScreenJobEdit2";
|
||||
|
||||
export default function JobEdit() {
|
||||
const { id } = useLocalSearchParams();
|
||||
const [data, setData] = useState<any>({
|
||||
title: "",
|
||||
content: "",
|
||||
deskripsi: "",
|
||||
});
|
||||
const [isLoadData, setIsLoadData] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [imageUri, setImageUri] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
onLoadData();
|
||||
}, [id]);
|
||||
|
||||
const onLoadData = async () => {
|
||||
try {
|
||||
setIsLoadData(true);
|
||||
const response = await apiJobGetOne({ id: id as string });
|
||||
if (response.success) {
|
||||
setData(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("[ERROR]", error);
|
||||
} finally {
|
||||
setIsLoadData(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlerOnUpdate = async () => {
|
||||
if (!data.title || !data.content || !data.deskripsi) {
|
||||
Toast.show({
|
||||
type: "info",
|
||||
text1: "Info",
|
||||
text2: "Harap isi semua data",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
let newImageId = "";
|
||||
|
||||
if (imageUri) {
|
||||
const responseUploadImage = await uploadFileService({
|
||||
imageUri: imageUri,
|
||||
dirId: DIRECTORY_ID.job_image,
|
||||
});
|
||||
|
||||
if (responseUploadImage.success) {
|
||||
newImageId = responseUploadImage.data.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (data?.imageId) {
|
||||
const responseDeleteImage = await deleteFileService({
|
||||
id: data.imageId,
|
||||
});
|
||||
|
||||
if (!responseDeleteImage.success) {
|
||||
console.log("[ERROR DELETE IMAGE]", responseDeleteImage.message);
|
||||
}
|
||||
}
|
||||
|
||||
const newData = {
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
deskripsi: data.deskripsi,
|
||||
imageId: newImageId,
|
||||
};
|
||||
|
||||
const response = await apiJobUpdateData({
|
||||
id: id as string,
|
||||
data: newData,
|
||||
category: "edit",
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
Toast.show({
|
||||
type: "success",
|
||||
text1: response.message,
|
||||
});
|
||||
router.back();
|
||||
} else {
|
||||
Toast.show({
|
||||
type: "info",
|
||||
text1: "Info",
|
||||
text2: response.message,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("[ERROR]", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const buttonSubmit = () => {
|
||||
return (
|
||||
<>
|
||||
<ButtonCustom isLoading={isLoading} onPress={() => handlerOnUpdate()}>
|
||||
Update
|
||||
</ButtonCustom>
|
||||
<Spacing />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ViewWrapper>
|
||||
{isLoadData ? (
|
||||
<LoaderCustom />
|
||||
) : (
|
||||
<StackCustom gap={"xs"}>
|
||||
<InformationBox text="Poster atau gambar lowongan kerja bersifat opsional, tidak wajib untuk dimasukkan dan upload lah gambar yang sesuai dengan deskripsi lowongan kerja." />
|
||||
|
||||
{imageUri ? (
|
||||
<LandscapeFrameUploaded image={imageUri as any} />
|
||||
) : (
|
||||
<BaseBox>
|
||||
<DummyLandscapeImage imageId={data?.imageId} />
|
||||
</BaseBox>
|
||||
)}
|
||||
|
||||
<ButtonCenteredOnly
|
||||
onPress={() => {
|
||||
pickImage({
|
||||
setImageUri,
|
||||
});
|
||||
}}
|
||||
icon="upload"
|
||||
>
|
||||
Upload
|
||||
</ButtonCenteredOnly>
|
||||
|
||||
<Spacing />
|
||||
|
||||
<TextInputCustom
|
||||
label="Judul Lowongan"
|
||||
placeholder="Masukan Judul Lowongan Kerja"
|
||||
required
|
||||
value={data.title}
|
||||
onChangeText={(value) => setData({ ...data, title: value })}
|
||||
/>
|
||||
|
||||
<TextAreaCustom
|
||||
label="Syarat & Kualifikasi"
|
||||
placeholder="Masukan Syarat & Kualifikasi Lowongan Kerja"
|
||||
required
|
||||
showCount
|
||||
maxLength={1000}
|
||||
value={data.content}
|
||||
onChangeText={(value) => setData({ ...data, content: value })}
|
||||
/>
|
||||
|
||||
<TextAreaCustom
|
||||
label="Deskripsi Lowongan"
|
||||
placeholder="Masukan Deskripsi Lowongan Kerja"
|
||||
required
|
||||
showCount
|
||||
maxLength={1000}
|
||||
value={data.deskripsi}
|
||||
onChangeText={(value) => setData({ ...data, deskripsi: value })}
|
||||
/>
|
||||
|
||||
{buttonSubmit()}
|
||||
</StackCustom>
|
||||
)}
|
||||
</ViewWrapper>
|
||||
<>
|
||||
{/* <Job_ScreenEdit />; */}
|
||||
<Job_ScreenEdit2 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,168 +1,11 @@
|
||||
import {
|
||||
BoxButtonOnFooter,
|
||||
ButtonCenteredOnly,
|
||||
ButtonCustom,
|
||||
InformationBox,
|
||||
LandscapeFrameUploaded,
|
||||
NewWrapper,
|
||||
Spacing,
|
||||
StackCustom,
|
||||
TextAreaCustom,
|
||||
TextInputCustom
|
||||
} from "@/components";
|
||||
import DIRECTORY_ID from "@/constants/directory-id";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { apiJobCreate } from "@/service/api-client/api-job";
|
||||
import { uploadFileService } from "@/service/upload-service";
|
||||
import pickImage from "@/utils/pickImage";
|
||||
import { router } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import Toast from "react-native-toast-message";
|
||||
import { Job_ScreenCreate } from "@/screens/Job/ScreenJobCreate";
|
||||
import { Job_ScreenCreate2 } from "@/screens/Job/ScreenJobCreate2";
|
||||
|
||||
export default function JobCreate() {
|
||||
const nextUrl = "/(application)/(user)/job/(tabs)/status?status=review";
|
||||
const { user } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [image, setImage] = useState<string | null>(null);
|
||||
const [data, setData] = useState({
|
||||
title: "",
|
||||
content: "",
|
||||
deskripsi: "",
|
||||
authorId: "",
|
||||
});
|
||||
|
||||
const handlerOnSubmit = async () => {
|
||||
let imageId = "";
|
||||
const newData = {
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
deskripsi: data.deskripsi,
|
||||
authorId: user?.id,
|
||||
imageId: "",
|
||||
};
|
||||
|
||||
if (!data.title || !data.content || !data.deskripsi || !user?.id) {
|
||||
Toast.show({
|
||||
type: "info",
|
||||
text1: "Info",
|
||||
text2: "Harap isi semua data",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
if (image === null || !image) {
|
||||
const response = await apiJobCreate(newData);
|
||||
if (response.success) {
|
||||
Toast.show({
|
||||
type: "success",
|
||||
text1: "Berhasil",
|
||||
text2: "Lowongan berhasil dibuat",
|
||||
});
|
||||
router.replace(nextUrl);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const responseUploadImage = await uploadFileService({
|
||||
imageUri: image,
|
||||
dirId: DIRECTORY_ID.job_image,
|
||||
});
|
||||
|
||||
if (responseUploadImage.success) {
|
||||
imageId = responseUploadImage.data.id;
|
||||
}
|
||||
|
||||
const fixData = {
|
||||
...newData,
|
||||
imageId: imageId,
|
||||
};
|
||||
|
||||
const response = await apiJobCreate(fixData);
|
||||
if (response.success) {
|
||||
Toast.show({
|
||||
type: "success",
|
||||
text1: "Berhasil",
|
||||
text2: "Lowongan berhasil dibuat",
|
||||
});
|
||||
router.replace(nextUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("[ERROR]", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const buttonSubmit = () => {
|
||||
return (
|
||||
<>
|
||||
<BoxButtonOnFooter>
|
||||
<ButtonCustom isLoading={isLoading} onPress={() => handlerOnSubmit()}>
|
||||
Simpan
|
||||
</ButtonCustom>
|
||||
</BoxButtonOnFooter>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<NewWrapper footerComponent={buttonSubmit()}>
|
||||
<StackCustom gap={"xs"}>
|
||||
<InformationBox text="Poster atau gambar lowongan kerja bersifat opsional, tidak wajib untuk dimasukkan dan upload lah gambar yang sesuai dengan deskripsi lowongan kerja." />
|
||||
|
||||
{/* <BaseBox>
|
||||
<Image
|
||||
source={image ? { uri: image } : DUMMY_IMAGE.dummy_image}
|
||||
style={{ width: "100%", height: 200 }}
|
||||
/>
|
||||
</BaseBox> */}
|
||||
<LandscapeFrameUploaded image={image as string} />
|
||||
<ButtonCenteredOnly
|
||||
onPress={() => {
|
||||
// router.push("/(application)/(image)/take-picture/123");
|
||||
pickImage({
|
||||
setImageUri: setImage,
|
||||
});
|
||||
}}
|
||||
icon="upload"
|
||||
>
|
||||
Upload
|
||||
</ButtonCenteredOnly>
|
||||
|
||||
<Spacing />
|
||||
|
||||
<TextInputCustom
|
||||
label="Judul Lowongan"
|
||||
placeholder="Masukan Judul Lowongan Kerja"
|
||||
required
|
||||
value={data.title}
|
||||
onChangeText={(value) => setData({ ...data, title: value })}
|
||||
/>
|
||||
|
||||
<TextAreaCustom
|
||||
label="Syarat & Kualifikasi"
|
||||
placeholder="Masukan Syarat & Kualifikasi Lowongan Kerja"
|
||||
required
|
||||
showCount
|
||||
maxLength={1000}
|
||||
value={data.content}
|
||||
onChangeText={(value) => setData({ ...data, content: value })}
|
||||
/>
|
||||
|
||||
<TextAreaCustom
|
||||
label="Deskripsi Lowongan"
|
||||
placeholder="Masukan Deskripsi Lowongan Kerja"
|
||||
required
|
||||
showCount
|
||||
maxLength={1000}
|
||||
value={data.deskripsi}
|
||||
onChangeText={(value) => setData({ ...data, deskripsi: value })}
|
||||
/>
|
||||
</StackCustom>
|
||||
</NewWrapper>
|
||||
<>
|
||||
{/* <Job_ScreenCreate /> */}
|
||||
<Job_ScreenCreate2/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
73
components/_ShareComponent/FormWrapper.tsx
Normal file
73
components/_ShareComponent/FormWrapper.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
// FormWrapper.tsx - Reusable wrapper untuk form dengan keyboard handling
|
||||
import { MainColor } from "@/constants/color-palet";
|
||||
import { Keyboard, KeyboardAvoidingView, Platform, ScrollView, TouchableWithoutFeedback, View } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { ReactNode } from "react";
|
||||
import { useKeyboardForm } from "@/hooks/useKeyboardForm";
|
||||
|
||||
interface FormWrapperProps {
|
||||
children: ReactNode;
|
||||
footerComponent?: ReactNode;
|
||||
/**
|
||||
* Offset scroll saat keyboard muncul (default: 100)
|
||||
*/
|
||||
scrollOffset?: number;
|
||||
/**
|
||||
* Padding bottom untuk content (default: 100)
|
||||
*/
|
||||
contentPaddingBottom?: number;
|
||||
/**
|
||||
* Padding untuk content container (default: 16)
|
||||
*/
|
||||
contentPadding?: number;
|
||||
}
|
||||
|
||||
export function FormWrapper({
|
||||
children,
|
||||
footerComponent,
|
||||
scrollOffset = 100,
|
||||
contentPaddingBottom = 100,
|
||||
contentPadding = 16,
|
||||
}: FormWrapperProps) {
|
||||
const { scrollViewRef, handleInputFocus } = useKeyboardForm(scrollOffset);
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
style={{ flex: 1, backgroundColor: MainColor.darkblue }}
|
||||
>
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
paddingBottom: contentPaddingBottom,
|
||||
}}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
|
||||
<View style={{ flex: 1, padding: contentPadding }}>
|
||||
{children}
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</ScrollView>
|
||||
|
||||
{/* Footer - Fixed di bawah */}
|
||||
{footerComponent && (
|
||||
<SafeAreaView
|
||||
edges={["bottom"]}
|
||||
style={{
|
||||
backgroundColor: MainColor.darkblue,
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
}}
|
||||
>
|
||||
{footerComponent}
|
||||
</SafeAreaView>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
@@ -117,15 +117,15 @@ const NewWrapper = (props: NewWrapperProps) => {
|
||||
ListHeaderComponent={listProps.ListHeaderComponent}
|
||||
ListFooterComponent={listProps.ListFooterComponent}
|
||||
ListEmptyComponent={listProps.ListEmptyComponent}
|
||||
contentContainerStyle={{
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
paddingBottom: footerComponent && !hideFooter ? OS_HEIGHT : 0
|
||||
paddingBottom: footerComponent && !hideFooter ? OS_HEIGHT : 0
|
||||
}}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Footer dengan position absolute untuk stay di bawah */}
|
||||
{/* Footer - tetap di bawah dengan position absolute */}
|
||||
{footerComponent && !hideFooter && (
|
||||
<View style={styles.footerContainer}>
|
||||
<SafeAreaView
|
||||
@@ -133,7 +133,7 @@ const NewWrapper = (props: NewWrapperProps) => {
|
||||
style={{ backgroundColor: MainColor.darkblue }}
|
||||
>
|
||||
{footerComponent}
|
||||
</SafeAreaView>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -163,11 +163,11 @@ const NewWrapper = (props: NewWrapperProps) => {
|
||||
<View style={GStyles.stickyHeader}>{headerComponent}</View>
|
||||
)}
|
||||
|
||||
<View style={{ flex: 0 }} collapsable={false}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
paddingBottom: footerComponent && !hideFooter ? OS_HEIGHT : 0
|
||||
paddingBottom: footerComponent && !hideFooter ? OS_HEIGHT : 0
|
||||
}}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
refreshControl={refreshControl}
|
||||
@@ -178,7 +178,7 @@ const NewWrapper = (props: NewWrapperProps) => {
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* Footer dengan position absolute untuk stay di bawah */}
|
||||
{/* Footer - tetap di bawah dengan position absolute */}
|
||||
{footerComponent && !hideFooter && (
|
||||
<View style={styles.footerContainer}>
|
||||
<SafeAreaView
|
||||
|
||||
223
components/_ShareComponent/NewWrapper_V2.tsx
Normal file
223
components/_ShareComponent/NewWrapper_V2.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
// NewWrapper_V2.tsx - Wrapper baru dengan keyboard handling
|
||||
import { MainColor } from "@/constants/color-palet";
|
||||
import { OS_HEIGHT } from "@/constants/constans-value";
|
||||
import { GStyles } from "@/styles/global-styles";
|
||||
import {
|
||||
ImageBackground,
|
||||
Keyboard,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
FlatList,
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
StyleProp,
|
||||
ViewStyle,
|
||||
} from "react-native";
|
||||
import {
|
||||
NativeSafeAreaViewProps,
|
||||
SafeAreaView,
|
||||
} from "react-native-safe-area-context";
|
||||
import type { ScrollViewProps, FlatListProps } from "react-native";
|
||||
import Spacing from "./Spacing";
|
||||
import { useKeyboardForm } from "@/hooks/useKeyboardForm";
|
||||
|
||||
interface BaseProps {
|
||||
withBackground?: boolean;
|
||||
headerComponent?: React.ReactNode;
|
||||
footerComponent?: React.ReactNode;
|
||||
floatingButton?: React.ReactNode;
|
||||
hideFooter?: boolean;
|
||||
edgesFooter?: NativeSafeAreaViewProps["edges"];
|
||||
style?: StyleProp<ViewStyle>;
|
||||
refreshControl?: ScrollViewProps["refreshControl"];
|
||||
/**
|
||||
* Enable keyboard handling with auto-scroll
|
||||
* @default false
|
||||
*/
|
||||
enableKeyboardHandling?: boolean;
|
||||
/**
|
||||
* Scroll offset when keyboard appears (default: 100)
|
||||
*/
|
||||
keyboardScrollOffset?: number;
|
||||
/**
|
||||
* Extra padding bottom for content to avoid navigation bar (default: 80)
|
||||
*/
|
||||
contentPaddingBottom?: number;
|
||||
}
|
||||
|
||||
interface StaticModeProps extends BaseProps {
|
||||
children: React.ReactNode;
|
||||
listData?: never;
|
||||
renderItem?: never;
|
||||
}
|
||||
|
||||
interface ListModeProps extends BaseProps {
|
||||
children?: never;
|
||||
listData?: any[];
|
||||
renderItem?: FlatListProps<any>["renderItem"];
|
||||
onEndReached?: () => void;
|
||||
ListHeaderComponent?: React.ReactElement | null;
|
||||
ListFooterComponent?: React.ReactElement | null;
|
||||
ListEmptyComponent?: React.ReactElement | null;
|
||||
keyExtractor?: FlatListProps<any>["keyExtractor"];
|
||||
}
|
||||
|
||||
type NewWrapper_V2_Props = StaticModeProps | ListModeProps;
|
||||
|
||||
export function NewWrapper_V2(props: NewWrapper_V2_Props) {
|
||||
const {
|
||||
withBackground = false,
|
||||
headerComponent,
|
||||
footerComponent,
|
||||
floatingButton,
|
||||
hideFooter = false,
|
||||
edgesFooter = [],
|
||||
style,
|
||||
refreshControl,
|
||||
enableKeyboardHandling = false,
|
||||
keyboardScrollOffset = 100,
|
||||
contentPaddingBottom = 80, // Default 80 untuk navigasi device
|
||||
} = props;
|
||||
|
||||
const assetBackground = require("../../assets/images/main-background.png");
|
||||
|
||||
// Use keyboard hook if enabled
|
||||
const keyboardForm = enableKeyboardHandling
|
||||
? useKeyboardForm(keyboardScrollOffset)
|
||||
: null;
|
||||
|
||||
const renderContainer = (content: React.ReactNode) => {
|
||||
if (withBackground) {
|
||||
return (
|
||||
<ImageBackground
|
||||
source={assetBackground}
|
||||
resizeMode="cover"
|
||||
style={GStyles.imageBackground}
|
||||
>
|
||||
<View style={[GStyles.containerWithBackground, style]}>
|
||||
{content}
|
||||
</View>
|
||||
</ImageBackground>
|
||||
);
|
||||
}
|
||||
return <View style={[GStyles.container, style]}>{content}</View>;
|
||||
};
|
||||
|
||||
// 🔹 Mode Dinamis (FlatList)
|
||||
if ("listData" in props) {
|
||||
const listProps = props as ListModeProps;
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
style={{ flex: 1, backgroundColor: MainColor.darkblue }}
|
||||
>
|
||||
{headerComponent && (
|
||||
<View style={GStyles.stickyHeader}>{headerComponent}</View>
|
||||
)}
|
||||
<FlatList
|
||||
data={listProps.listData}
|
||||
renderItem={listProps.renderItem}
|
||||
keyExtractor={
|
||||
listProps.keyExtractor ||
|
||||
((item, index) => `${String(item.id)}-${index}`)
|
||||
}
|
||||
refreshControl={refreshControl}
|
||||
onEndReached={listProps.onEndReached}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListHeaderComponent={listProps.ListHeaderComponent}
|
||||
ListFooterComponent={listProps.ListFooterComponent}
|
||||
ListEmptyComponent={listProps.ListEmptyComponent}
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
paddingBottom: (footerComponent && !hideFooter ? OS_HEIGHT : 0) + contentPaddingBottom,
|
||||
}}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
/>
|
||||
|
||||
{/* Footer - Fixed di bawah dengan width 100% */}
|
||||
{footerComponent && !hideFooter && (
|
||||
<SafeAreaView
|
||||
edges={Platform.OS === "ios" ? edgesFooter : ["bottom"]}
|
||||
style={{ backgroundColor: MainColor.darkblue, width: "100%" }}
|
||||
>
|
||||
<View style={{ width: "100%" }}>
|
||||
{footerComponent}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
)}
|
||||
|
||||
{!footerComponent && !hideFooter && (
|
||||
<SafeAreaView
|
||||
edges={["bottom"]}
|
||||
style={{ backgroundColor: MainColor.darkblue }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{floatingButton && (
|
||||
<View style={GStyles.floatingContainer}>{floatingButton}</View>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
// 🔹 Mode Statis (ScrollView)
|
||||
const staticProps = props as StaticModeProps;
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
style={{ flex: 1, backgroundColor: MainColor.darkblue }}
|
||||
>
|
||||
{headerComponent && (
|
||||
<View style={GStyles.stickyHeader}>{headerComponent}</View>
|
||||
)}
|
||||
|
||||
<ScrollView
|
||||
ref={keyboardForm?.scrollViewRef}
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
paddingBottom: (footerComponent && !hideFooter ? OS_HEIGHT : 0) + contentPaddingBottom,
|
||||
}}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
|
||||
{renderContainer(staticProps.children)}
|
||||
</TouchableWithoutFeedback>
|
||||
</ScrollView>
|
||||
|
||||
{/* Footer - Fixed di bawah dengan width 100% */}
|
||||
{footerComponent && !hideFooter && (
|
||||
<SafeAreaView
|
||||
edges={["bottom"]}
|
||||
style={{
|
||||
backgroundColor: MainColor.darkblue,
|
||||
width: "100%",
|
||||
position: Platform.OS === "android" ? "absolute" : undefined,
|
||||
bottom: Platform.OS === "android" ? 0 : undefined,
|
||||
left: 0,
|
||||
right: 0,
|
||||
}}
|
||||
>
|
||||
<View style={{ width: "100%" }}>
|
||||
{footerComponent}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
)}
|
||||
|
||||
{!footerComponent && !hideFooter && (
|
||||
<SafeAreaView
|
||||
edges={["bottom"]}
|
||||
style={{ backgroundColor: MainColor.darkblue }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{floatingButton && (
|
||||
<View style={GStyles.floatingContainer}>{floatingButton}</View>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
45
components/_ShareComponent/TestWrapper.tsx
Normal file
45
components/_ShareComponent/TestWrapper.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
// TestWrapper.tsx - Wrapper sederhana untuk test keyboard handling
|
||||
import { MainColor } from "@/constants/color-palet";
|
||||
import {
|
||||
Keyboard,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
View,
|
||||
} from "react-native";
|
||||
import {
|
||||
NativeSafeAreaViewProps,
|
||||
SafeAreaView,
|
||||
} from "react-native-safe-area-context";
|
||||
|
||||
interface TestWrapperProps {
|
||||
children: React.ReactNode;
|
||||
footerComponent?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function TestWrapper({ children, footerComponent }: TestWrapperProps) {
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior="padding" // ← FIX: Gunakan padding untuk iOS & Android (NOT "height" untuk Android!)
|
||||
style={{ flex: 1, backgroundColor: MainColor.darkblue }}
|
||||
keyboardVerticalOffset={0}
|
||||
>
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{ flexGrow: 1 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View style={{ flex: 1, padding: 10 }}>{children}</View>
|
||||
</ScrollView>
|
||||
|
||||
{footerComponent && (
|
||||
<SafeAreaView
|
||||
edges={["bottom"]}
|
||||
style={{ flex: 1, backgroundColor: MainColor.red }}
|
||||
>
|
||||
{footerComponent}
|
||||
</SafeAreaView>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
@@ -63,6 +63,10 @@ import DummyLandscapeImage from "./_ShareComponent/DummyLandscapeImage";
|
||||
import GridComponentView from "./_ShareComponent/GridSectionView";
|
||||
import NewWrapper from "./_ShareComponent/NewWrapper";
|
||||
import BasicWrapper from "./_ShareComponent/BasicWrapper";
|
||||
import { TestWrapper } from "./_ShareComponent/TestWrapper";
|
||||
import { FormWrapper } from "./_ShareComponent/FormWrapper";
|
||||
import { NewWrapper_V2 } from "./_ShareComponent/NewWrapper_V2";
|
||||
|
||||
// Progress
|
||||
import ProgressCustom from "./Progress/ProgressCustom";
|
||||
// Loader
|
||||
@@ -127,6 +131,9 @@ export {
|
||||
Spacing,
|
||||
NewWrapper,
|
||||
BasicWrapper,
|
||||
TestWrapper,
|
||||
FormWrapper,
|
||||
NewWrapper_V2,
|
||||
// Stack
|
||||
StackCustom,
|
||||
TabBarBackground,
|
||||
|
||||
148
docs/KEYBOARD-BUG-TEST.md
Normal file
148
docs/KEYBOARD-BUG-TEST.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# Keyboard Bug Investigation
|
||||
|
||||
## 🐛 Problem
|
||||
|
||||
Footer terangkat dan muncul area putih di bawah saat keyboard ditutup setelah input ke TextInput.
|
||||
|
||||
## 📋 Test Cases
|
||||
|
||||
### Test 1: Minimal Wrapper
|
||||
**File**: `test-keyboard-bug.tsx`
|
||||
|
||||
Wrapper yang sangat sederhana:
|
||||
```typescript
|
||||
<KeyboardAvoidingView behavior="height">
|
||||
<ScrollView>
|
||||
<TextInput />
|
||||
</ScrollView>
|
||||
<SafeAreaView>Footer</SafeAreaView>
|
||||
</KeyboardAvoidingView>
|
||||
```
|
||||
|
||||
**Expected**: Footer tetap di bawah
|
||||
**Actual**: ? (To be tested)
|
||||
|
||||
### Test 2: Original NewWrapper
|
||||
**File**: `components/_ShareComponent/NewWrapper.tsx`
|
||||
|
||||
Wrapper yang digunakan di production:
|
||||
```typescript
|
||||
<KeyboardAvoidingView behavior="height">
|
||||
<View flex={0}>
|
||||
<ScrollView>
|
||||
{content}
|
||||
</ScrollView>
|
||||
</View>
|
||||
<View position="absolute">Footer</View>
|
||||
</KeyboardAvoidingView>
|
||||
```
|
||||
|
||||
**Expected**: Footer tetap di bawah
|
||||
**Actual**: Footer terangkat, ada putih di bawah
|
||||
|
||||
## 🔍 Possible Causes
|
||||
|
||||
### 1. KeyboardAvoidingView Behavior
|
||||
- **Android**: `behavior="height"` mengurangi height view saat keyboard muncul
|
||||
- **Issue**: Saat keyboard close, height tidak kembali ke semula
|
||||
|
||||
### 2. View Wrapper dengan flex: 0
|
||||
- NewWrapper menggunakan `<View style={{ flex: 0 }}>`
|
||||
- Ini membuat ScrollView tidak expand dengan benar
|
||||
- **Fix**: Coba `<View style={{ flex: 1 }}>`
|
||||
|
||||
### 3. Footer dengan position: absolute
|
||||
- Footer "melayang" di atas konten
|
||||
- Tidak ikut terdorong saat keyboard muncul
|
||||
- Saat keyboard close, footer kembali tapi layout sudah berubah
|
||||
|
||||
### 4. SafeAreaView Insets
|
||||
- Safe area insets berubah saat keyboard muncul
|
||||
- Footer tidak handle insets dengan benar
|
||||
|
||||
## 🧪 Test Scenarios
|
||||
|
||||
1. **Test Input Focus**
|
||||
- [ ] Tap Input 1 → Keyboard muncul
|
||||
- [ ] Footer tetap di bawah?
|
||||
|
||||
2. **Test Input Blur**
|
||||
- [ ] Tap Input 1 → Keyboard muncul
|
||||
- [ ] Tap outside → Keyboard close
|
||||
- [ ] Footer kembali ke posisi?
|
||||
- [ ] Ada putih di bawah?
|
||||
|
||||
3. **Test Multiple Inputs**
|
||||
- [ ] Tap Input 1 → Input 2 → Input 3
|
||||
- [ ] Keyboard pindah dengan smooth
|
||||
- [ ] Footer tetap di bawah?
|
||||
|
||||
4. **Test Scroll After Close**
|
||||
- [ ] Input → Close keyboard
|
||||
- [ ] Scroll ke bawah
|
||||
- [ ] Footer terlihat?
|
||||
- [ ] Ada putih di bawah?
|
||||
|
||||
## 🔧 Potential Fixes
|
||||
|
||||
### Fix 1: Remove position: absolute
|
||||
```typescript
|
||||
// Before
|
||||
<View style={{ position: "absolute", bottom: 0 }}>
|
||||
{footer}
|
||||
</View>
|
||||
|
||||
// After
|
||||
<SafeAreaView>
|
||||
{footer}
|
||||
</SafeAreaView>
|
||||
```
|
||||
|
||||
### Fix 2: Use flex: 1 instead of flex: 0
|
||||
```typescript
|
||||
// Before
|
||||
<View style={{ flex: 0 }}>
|
||||
<ScrollView />
|
||||
</View>
|
||||
|
||||
// After
|
||||
<View style={{ flex: 1 }}>
|
||||
<ScrollView />
|
||||
</View>
|
||||
```
|
||||
|
||||
### Fix 3: Use KeyboardAwareScrollView
|
||||
```typescript
|
||||
import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'
|
||||
|
||||
<KeyboardAwareScrollView>
|
||||
{content}
|
||||
</KeyboardAwareScrollView>
|
||||
```
|
||||
|
||||
### Fix 4: Manual keyboard handling
|
||||
```typescript
|
||||
const [keyboardVisible, setKeyboardVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const show = Keyboard.addListener('keyboardDidShow', () => setKeyboardVisible(true));
|
||||
const hide = Keyboard.addListener('keyboardDidHide', () => setKeyboardVisible(false));
|
||||
return () => { show.remove(); hide.remove(); }
|
||||
}, []);
|
||||
```
|
||||
|
||||
## 📝 Test Results
|
||||
|
||||
| Test | Platform | Result | Notes |
|
||||
|------|----------|--------|-------|
|
||||
| Test 1 (Minimal) | Android | ? | TBD |
|
||||
| Test 1 (Minimal) | iOS | ? | TBD |
|
||||
| Test 2 (Original) | Android | ❌ Bug | Footer terangkat |
|
||||
| Test 2 (Original) | iOS | ? | TBD |
|
||||
|
||||
## 🎯 Next Steps
|
||||
|
||||
1. Test dengan `TestWrapper` (minimal wrapper)
|
||||
2. Identifikasi apakah bug dari wrapper atau React Native
|
||||
3. Apply fix yang sesuai
|
||||
4. Test di semua screen
|
||||
346
docs/NEWWRAPPER-KEYBOARD-IMPLEMENTATION.md
Normal file
346
docs/NEWWRAPPER-KEYBOARD-IMPLEMENTATION.md
Normal file
@@ -0,0 +1,346 @@
|
||||
# NewWrapper Keyboard Handling Implementation
|
||||
|
||||
## 📋 Problem Statement
|
||||
|
||||
NewWrapper saat ini memiliki masalah keyboard handling pada Android:
|
||||
- Footer terangkat saat keyboard close
|
||||
- Muncul area putih di bawah
|
||||
- Input terpotong saat keyboard muncul
|
||||
- Tidak ada auto-scroll ke focused input
|
||||
|
||||
## 🔍 Root Cause Analysis
|
||||
|
||||
### Current NewWrapper Structure
|
||||
|
||||
```typescript
|
||||
<KeyboardAvoidingView behavior={Platform.OS === "ios" ? "padding" : "height"}>
|
||||
<View style={{ flex: 0 }}> // ← MASALAH 1: flex: 0
|
||||
<ScrollView>
|
||||
{children}
|
||||
</ScrollView>
|
||||
</View>
|
||||
<View style={{ position: "absolute" }}> // ← MASALAH 2: position absolute
|
||||
{footerComponent}
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
```
|
||||
|
||||
### Issues Identified
|
||||
|
||||
| Issue | Impact | Severity |
|
||||
|-------|--------|----------|
|
||||
| `behavior="height"` di Android | View di-resize, content terpotong | 🔴 High |
|
||||
| `flex: 0` pada View wrapper | ScrollView tidak expand dengan benar | 🔴 High |
|
||||
| Footer dengan `position: absolute` | Footer tidak ikut layout flow | 🟡 Medium |
|
||||
| Tidak ada keyboard event handling | Tidak ada auto-scroll ke input | 🟡 Medium |
|
||||
|
||||
---
|
||||
|
||||
## 💡 Proposed Solutions
|
||||
|
||||
### Option A: Full Integration (Breaking Changes)
|
||||
|
||||
Replace entire KeyboardAvoidingView logic dengan keyboard handling baru.
|
||||
|
||||
```typescript
|
||||
// NewWrapper.tsx
|
||||
export function NewWrapper({ children, footerComponent }: Props) {
|
||||
const { scrollViewRef, createFocusHandler } = useKeyboardForm();
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView behavior={Platform.OS === "ios" ? "padding" : undefined}>
|
||||
<ScrollView ref={scrollViewRef} style={{ flex: 1 }}>
|
||||
{children}
|
||||
</ScrollView>
|
||||
<SafeAreaView style={{ position: 'absolute', bottom: 0 }}>
|
||||
{footerComponent}
|
||||
</SafeAreaView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- ✅ Clean implementation
|
||||
- ✅ Consistent behavior across all screens
|
||||
- ✅ Single source of truth
|
||||
|
||||
**Cons:**
|
||||
- ❌ **Breaking changes** - Semua screen yang pakai NewWrapper akan affected
|
||||
- ❌ **Need to add onFocus handlers** to all TextInput/TextArea components
|
||||
- ❌ **High risk** - May break existing screens
|
||||
- ❌ **Requires testing** all screens that use NewWrapper
|
||||
|
||||
**Impact:**
|
||||
- All existing screens using NewWrapper will be affected
|
||||
- Need to add `onFocus` handlers to all inputs
|
||||
- Need to wrap inputs with `View onStartShouldSetResponder`
|
||||
|
||||
---
|
||||
|
||||
### Option B: Opt-in Feature (Recommended) ⭐
|
||||
|
||||
Add flag to enable keyboard handling optionally (backward compatible).
|
||||
|
||||
```typescript
|
||||
// NewWrapper.tsx
|
||||
interface NewWrapperProps {
|
||||
// ... existing props
|
||||
enableKeyboardHandling?: boolean; // Default: false
|
||||
keyboardScrollOffset?: number; // Default: 100
|
||||
}
|
||||
|
||||
export function NewWrapper(props: NewWrapperProps) {
|
||||
const {
|
||||
enableKeyboardHandling = false,
|
||||
keyboardScrollOffset = 100,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
// Use keyboard hook if enabled
|
||||
const keyboardForm = enableKeyboardHandling
|
||||
? useKeyboardForm(keyboardScrollOffset)
|
||||
: null;
|
||||
|
||||
// Render different structure based on flag
|
||||
if (enableKeyboardHandling && keyboardForm) {
|
||||
return renderWithKeyboardHandling(rest, keyboardForm);
|
||||
}
|
||||
|
||||
return renderOriginal(rest);
|
||||
}
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- ✅ **Backward compatible** - No breaking changes
|
||||
- ✅ **Opt-in** - Screens yang butuh bisa enable
|
||||
- ✅ **Safe** - Existing screens tetap bekerja
|
||||
- ✅ **Gradual migration** - Bisa migrate screen by screen
|
||||
- ✅ **Low risk** - Can test with new screens first
|
||||
|
||||
**Cons:**
|
||||
- ⚠️ More code (duplicate logic)
|
||||
- ⚠️ Need to maintain 2 implementations temporarily
|
||||
|
||||
**Usage Example:**
|
||||
|
||||
```typescript
|
||||
// Existing screens - No changes needed!
|
||||
<NewWrapper footerComponent={<Footer />}>
|
||||
<Content />
|
||||
</NewWrapper>
|
||||
|
||||
// New screens with forms - Enable keyboard handling
|
||||
<NewWrapper
|
||||
enableKeyboardHandling
|
||||
keyboardScrollOffset={100}
|
||||
footerComponent={<Footer />}
|
||||
>
|
||||
<View onStartShouldSetResponder={() => true}>
|
||||
<TextInputCustom onFocus={keyboardForm.createFocusHandler()} />
|
||||
</View>
|
||||
</NewWrapper>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Option C: Create New Component (Safest)
|
||||
|
||||
Keep NewWrapper as is, create separate component for forms.
|
||||
|
||||
```typescript
|
||||
// Keep NewWrapper unchanged
|
||||
// Use FormWrapper for forms (already created!)
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- ✅ **Zero risk** - NewWrapper tidak berubah
|
||||
- ✅ **Clear separation** - Old vs New
|
||||
- ✅ **Safe for existing screens**
|
||||
- ✅ **FormWrapper already exists!**
|
||||
|
||||
**Cons:**
|
||||
- ⚠️ Multiple wrapper components
|
||||
- ⚠️ Confusion which one to use
|
||||
|
||||
**Usage:**
|
||||
```typescript
|
||||
// For regular screens
|
||||
<NewWrapper>{content}</NewWrapper>
|
||||
|
||||
// For form screens
|
||||
<FormWrapper footerComponent={<Footer />}>
|
||||
<TextInputCustom />
|
||||
</FormWrapper>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Comparison Matrix
|
||||
|
||||
| Criteria | Option A | Option B | Option C |
|
||||
|----------|----------|----------|----------|
|
||||
| **Backward Compatible** | ❌ | ✅ | ✅ |
|
||||
| **Implementation Effort** | High | Medium | Low |
|
||||
| **Risk Level** | 🔴 High | 🟡 Medium | 🟢 Low |
|
||||
| **Code Duplication** | None | Temporary | Permanent |
|
||||
| **Migration Required** | Yes | Gradual | No |
|
||||
| **Testing Required** | All screens | New screens only | New screens only |
|
||||
| **Recommended For** | Greenfield projects | Existing projects | Conservative teams |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Recommended Approach: Option B (Opt-in)
|
||||
|
||||
### Implementation Plan
|
||||
|
||||
#### Phase 1: Add Keyboard Handling to NewWrapper (Week 1)
|
||||
|
||||
```typescript
|
||||
// Add to NewWrapper interface
|
||||
interface NewWrapperProps {
|
||||
enableKeyboardHandling?: boolean;
|
||||
keyboardScrollOffset?: number;
|
||||
}
|
||||
|
||||
// Implement dual rendering logic
|
||||
if (enableKeyboardHandling) {
|
||||
return renderWithKeyboardHandling(props);
|
||||
}
|
||||
return renderOriginal(props);
|
||||
```
|
||||
|
||||
#### Phase 2: Test with New Screens (Week 2)
|
||||
|
||||
- Test with Job Create 2 screen
|
||||
- Verify auto-scroll works
|
||||
- Verify footer stays in place
|
||||
- Test on iOS and Android
|
||||
|
||||
#### Phase 3: Gradual Migration (Week 3-4)
|
||||
|
||||
Migrate screens one by one:
|
||||
1. Event Create
|
||||
2. Donation Create
|
||||
3. Investment Create
|
||||
4. Voting Create
|
||||
5. Profile Create/Edit
|
||||
|
||||
#### Phase 4: Make Default (Next Major Version)
|
||||
|
||||
After thorough testing:
|
||||
- Make `enableKeyboardHandling` default to `true`
|
||||
- Deprecate old behavior
|
||||
- Remove old code in next major version
|
||||
|
||||
---
|
||||
|
||||
## 📝 Technical Requirements
|
||||
|
||||
### For NewWrapper with Keyboard Handling
|
||||
|
||||
```typescript
|
||||
// 1. Import hook
|
||||
import { useKeyboardForm } from "@/hooks/useKeyboardForm";
|
||||
|
||||
// 2. Use hook in component
|
||||
const { scrollViewRef, createFocusHandler } = useKeyboardForm(100);
|
||||
|
||||
// 3. Pass ref to ScrollView
|
||||
<ScrollView ref={scrollViewRef}>
|
||||
|
||||
// 4. Wrap inputs with View
|
||||
<View onStartShouldSetResponder={() => true}>
|
||||
<TextInputCustom onFocus={createFocusHandler()} />
|
||||
</View>
|
||||
```
|
||||
|
||||
### Required Changes per Screen
|
||||
|
||||
For each screen that enables keyboard handling:
|
||||
|
||||
1. **Add `enableKeyboardHandling` prop**
|
||||
2. **Wrap all TextInput/TextArea with View**
|
||||
3. **Add `onFocus` handler to inputs**
|
||||
4. **Test thoroughly**
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing Checklist
|
||||
|
||||
### For Each Screen
|
||||
|
||||
- [ ] Tap Input 1 → Auto-scroll to input
|
||||
- [ ] Tap Input 2 → Auto-scroll to input
|
||||
- [ ] Tap Input 3 → Auto-scroll to input
|
||||
- [ ] Dismiss keyboard → Footer returns to position
|
||||
- [ ] No white area at bottom
|
||||
- [ ] Footer not raised
|
||||
- [ ] Smooth transitions
|
||||
- [ ] iOS compatibility
|
||||
- [ ] Android compatibility
|
||||
|
||||
### Platforms to Test
|
||||
|
||||
- [ ] Android with navigation buttons
|
||||
- [ ] Android with gesture navigation
|
||||
- [ ] iOS with home button
|
||||
- [ ] iOS with gesture (notch)
|
||||
- [ ] Various screen sizes
|
||||
|
||||
---
|
||||
|
||||
## 📋 Decision Factors
|
||||
|
||||
### Choose Option A if:
|
||||
- ✅ Project is new (few existing screens)
|
||||
- ✅ Team has time for full migration
|
||||
- ✅ Want clean codebase immediately
|
||||
- ✅ Accept short-term disruption
|
||||
|
||||
### Choose Option B if: ⭐
|
||||
- ✅ Existing project with many screens
|
||||
- ✅ Want zero disruption to users
|
||||
- ✅ Prefer gradual migration
|
||||
- ✅ Want to test thoroughly first
|
||||
|
||||
### Choose Option C if:
|
||||
- ✅ Very conservative team
|
||||
- ✅ Cannot risk any changes to existing screens
|
||||
- ✅ OK with multiple wrapper components
|
||||
- ✅ FormWrapper is sufficient
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Next Steps
|
||||
|
||||
1. **Review this document** with team
|
||||
2. **Decide on approach** (A, B, or C)
|
||||
3. **Create implementation ticket**
|
||||
4. **Start with Phase 1**
|
||||
5. **Test thoroughly**
|
||||
6. **Roll out gradually**
|
||||
|
||||
---
|
||||
|
||||
## 📚 Related Files
|
||||
|
||||
- `components/_ShareComponent/NewWrapper.tsx` - Current wrapper
|
||||
- `components/_ShareComponent/FormWrapper.tsx` - New form wrapper
|
||||
- `hooks/useKeyboardForm.ts` - Keyboard handling hook
|
||||
- `screens/Job/ScreenJobCreate2.tsx` - Example implementation
|
||||
|
||||
---
|
||||
|
||||
## 📞 Discussion Points
|
||||
|
||||
1. **Which option do you prefer?** (A, B, or C)
|
||||
2. **How many screens use NewWrapper?**
|
||||
3. **Team capacity for migration?**
|
||||
4. **Timeline for implementation?**
|
||||
5. **Risk tolerance level?**
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2026-04-01
|
||||
**Status:** 📝 Under Discussion
|
||||
67
hooks/useKeyboardForm.ts
Normal file
67
hooks/useKeyboardForm.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
// useKeyboardForm.ts - Hook untuk keyboard handling pada form
|
||||
import { Keyboard, ScrollView } from "react-native";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
export function useKeyboardForm(scrollOffset = 100) {
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||
const [focusedInputY, setFocusedInputY] = useState<number | null>(null);
|
||||
|
||||
// Listen to keyboard events
|
||||
useEffect(() => {
|
||||
const keyboardDidShowListener = Keyboard.addListener(
|
||||
'keyboardDidShow',
|
||||
(e) => {
|
||||
setKeyboardHeight(e.endCoordinates.height);
|
||||
}
|
||||
);
|
||||
const keyboardDidHideListener = Keyboard.addListener(
|
||||
'keyboardDidHide',
|
||||
() => {
|
||||
setKeyboardHeight(0);
|
||||
setFocusedInputY(null);
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
keyboardDidShowListener.remove();
|
||||
keyboardDidHideListener.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Scroll ke focused input
|
||||
useEffect(() => {
|
||||
if (focusedInputY !== null && keyboardHeight > 0 && scrollViewRef.current) {
|
||||
setTimeout(() => {
|
||||
scrollViewRef.current?.scrollTo({
|
||||
y: Math.max(0, focusedInputY - scrollOffset),
|
||||
animated: true,
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
}, [focusedInputY, keyboardHeight, scrollOffset]);
|
||||
|
||||
// Handler untuk track focused input position
|
||||
const handleInputFocus = (yPosition: number) => {
|
||||
setFocusedInputY(yPosition);
|
||||
};
|
||||
|
||||
// Helper untuk create onFocus handler
|
||||
const createFocusHandler = () => {
|
||||
return (e: any) => {
|
||||
e.target?.measure((x: number, y: number, width: number, height: number, pageX: number, pageY: number) => {
|
||||
if (pageY !== null) {
|
||||
handleInputFocus(pageY);
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
scrollViewRef,
|
||||
keyboardHeight,
|
||||
focusedInputY,
|
||||
handleInputFocus,
|
||||
createFocusHandler,
|
||||
};
|
||||
}
|
||||
179
screens/Job/ScreenJobCreate.tsx
Normal file
179
screens/Job/ScreenJobCreate.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import {
|
||||
BoxButtonOnFooter,
|
||||
ButtonCenteredOnly,
|
||||
ButtonCustom,
|
||||
InformationBox,
|
||||
LandscapeFrameUploaded,
|
||||
NewWrapper_V2,
|
||||
Spacing,
|
||||
StackCustom,
|
||||
TextAreaCustom,
|
||||
TextInputCustom,
|
||||
} from "@/components";
|
||||
import DIRECTORY_ID from "@/constants/directory-id";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { apiJobCreate } from "@/service/api-client/api-job";
|
||||
import { uploadFileService } from "@/service/upload-service";
|
||||
import pickImage from "@/utils/pickImage";
|
||||
import { router } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import Toast from "react-native-toast-message";
|
||||
|
||||
interface JobCreateData {
|
||||
title: string;
|
||||
content: string;
|
||||
deskripsi: string;
|
||||
authorId: string;
|
||||
}
|
||||
|
||||
export function Job_ScreenCreate() {
|
||||
const nextUrl = "/(application)/(user)/job/(tabs)/status?status=review";
|
||||
const { user } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [image, setImage] = useState<string | null>(null);
|
||||
const [data, setData] = useState<JobCreateData>({
|
||||
title: "",
|
||||
content: "",
|
||||
deskripsi: "",
|
||||
authorId: "",
|
||||
});
|
||||
|
||||
const handlerOnSubmit = async () => {
|
||||
let imageId = "";
|
||||
const newData = {
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
deskripsi: data.deskripsi,
|
||||
authorId: user?.id,
|
||||
imageId: "",
|
||||
};
|
||||
|
||||
if (!data.title || !data.content || !data.deskripsi || !user?.id) {
|
||||
Toast.show({
|
||||
type: "info",
|
||||
text1: "Info",
|
||||
text2: "Harap isi semua data",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
if (image === null || !image) {
|
||||
const response = await apiJobCreate(newData);
|
||||
if (response.success) {
|
||||
Toast.show({
|
||||
type: "success",
|
||||
text1: "Berhasil",
|
||||
text2: "Lowongan berhasil dibuat",
|
||||
});
|
||||
router.replace(nextUrl);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const responseUploadImage = await uploadFileService({
|
||||
imageUri: image,
|
||||
dirId: DIRECTORY_ID.job_image,
|
||||
});
|
||||
|
||||
if (responseUploadImage.success) {
|
||||
imageId = responseUploadImage.data.id;
|
||||
}
|
||||
|
||||
const fixData = {
|
||||
...newData,
|
||||
imageId: imageId,
|
||||
};
|
||||
|
||||
const response = await apiJobCreate(fixData);
|
||||
if (response.success) {
|
||||
Toast.show({
|
||||
type: "success",
|
||||
text1: "Berhasil",
|
||||
text2: "Lowongan berhasil dibuat",
|
||||
});
|
||||
router.replace(nextUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("[ERROR]", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const buttonSubmit = () => {
|
||||
return (
|
||||
<>
|
||||
<BoxButtonOnFooter>
|
||||
<ButtonCustom isLoading={isLoading} onPress={() => handlerOnSubmit()}>
|
||||
Simpan
|
||||
</ButtonCustom>
|
||||
</BoxButtonOnFooter>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<NewWrapper_V2
|
||||
enableKeyboardHandling
|
||||
keyboardScrollOffset={100}
|
||||
footerComponent={buttonSubmit()}
|
||||
>
|
||||
<StackCustom gap={"xs"}>
|
||||
<InformationBox text="Poster atau gambar lowongan kerja bersifat opsional, tidak wajib untuk dimasukkan dan upload lah gambar yang sesuai dengan deskripsi lowongan kerja." />
|
||||
|
||||
<LandscapeFrameUploaded image={image as string} />
|
||||
<ButtonCenteredOnly
|
||||
onPress={() => {
|
||||
pickImage({
|
||||
setImageUri: setImage,
|
||||
});
|
||||
}}
|
||||
icon="upload"
|
||||
>
|
||||
Upload
|
||||
</ButtonCenteredOnly>
|
||||
|
||||
<Spacing />
|
||||
|
||||
<View onStartShouldSetResponder={() => true}>
|
||||
<TextInputCustom
|
||||
label="Judul Lowongan"
|
||||
placeholder="Masukan Judul Lowongan Kerja"
|
||||
required
|
||||
value={data.title}
|
||||
onChangeText={(value) => setData({ ...data, title: value })}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View onStartShouldSetResponder={() => true}>
|
||||
<TextAreaCustom
|
||||
label="Syarat & Kualifikasi"
|
||||
placeholder="Masukan Syarat & Kualifikasi Lowongan Kerja"
|
||||
required
|
||||
showCount
|
||||
maxLength={1000}
|
||||
value={data.content}
|
||||
onChangeText={(value) => setData({ ...data, content: value })}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View onStartShouldSetResponder={() => true}>
|
||||
<TextAreaCustom
|
||||
label="Deskripsi Lowongan"
|
||||
placeholder="Masukan Deskripsi Lowongan Kerja"
|
||||
required
|
||||
showCount
|
||||
maxLength={1000}
|
||||
value={data.deskripsi}
|
||||
onChangeText={(value) => setData({ ...data, deskripsi: value })}
|
||||
/>
|
||||
</View>
|
||||
</StackCustom>
|
||||
</NewWrapper_V2>
|
||||
);
|
||||
}
|
||||
183
screens/Job/ScreenJobCreate2.tsx
Normal file
183
screens/Job/ScreenJobCreate2.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
BoxButtonOnFooter,
|
||||
ButtonCenteredOnly,
|
||||
ButtonCustom,
|
||||
FormWrapper,
|
||||
InformationBox,
|
||||
LandscapeFrameUploaded,
|
||||
Spacing,
|
||||
StackCustom,
|
||||
TextAreaCustom,
|
||||
TextInputCustom,
|
||||
} from "@/components";
|
||||
import { MainColor } from "@/constants/color-palet";
|
||||
import DIRECTORY_ID from "@/constants/directory-id";
|
||||
import { useKeyboardForm } from "@/hooks/useKeyboardForm";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { apiJobCreate } from "@/service/api-client/api-job";
|
||||
import { uploadFileService } from "@/service/upload-service";
|
||||
import pickImage from "@/utils/pickImage";
|
||||
import { router } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import Toast from "react-native-toast-message";
|
||||
|
||||
interface JobCreateData {
|
||||
title: string;
|
||||
content: string;
|
||||
deskripsi: string;
|
||||
authorId: string;
|
||||
}
|
||||
|
||||
export function Job_ScreenCreate2() {
|
||||
const nextUrl = "/(application)/(user)/job/(tabs)/status?status=review";
|
||||
const { user } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [image, setImage] = useState<string | null>(null);
|
||||
const [data, setData] = useState<JobCreateData>({
|
||||
title: "",
|
||||
content: "",
|
||||
deskripsi: "",
|
||||
authorId: "",
|
||||
});
|
||||
|
||||
// Use keyboard form hook
|
||||
const { scrollViewRef, createFocusHandler } = useKeyboardForm(100);
|
||||
|
||||
const handlerOnSubmit = async () => {
|
||||
let imageId = "";
|
||||
const newData = {
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
deskripsi: data.deskripsi,
|
||||
authorId: user?.id,
|
||||
imageId: "",
|
||||
};
|
||||
|
||||
if (!data.title || !data.content || !data.deskripsi || !user?.id) {
|
||||
Toast.show({
|
||||
type: "info",
|
||||
text1: "Info",
|
||||
text2: "Harap isi semua data",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
if (image === null || !image) {
|
||||
const response = await apiJobCreate(newData);
|
||||
if (response.success) {
|
||||
Toast.show({
|
||||
type: "success",
|
||||
text1: "Berhasil",
|
||||
text2: "Lowongan berhasil dibuat",
|
||||
});
|
||||
router.replace(nextUrl);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const responseUploadImage = await uploadFileService({
|
||||
imageUri: image,
|
||||
dirId: DIRECTORY_ID.job_image,
|
||||
});
|
||||
|
||||
if (responseUploadImage.success) {
|
||||
imageId = responseUploadImage.data.id;
|
||||
}
|
||||
|
||||
const fixData = {
|
||||
...newData,
|
||||
imageId: imageId,
|
||||
};
|
||||
|
||||
const response = await apiJobCreate(fixData);
|
||||
if (response.success) {
|
||||
Toast.show({
|
||||
type: "success",
|
||||
text1: "Berhasil",
|
||||
text2: "Lowongan berhasil dibuat",
|
||||
});
|
||||
router.replace(nextUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("[ERROR]", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const buttonSubmit = () => {
|
||||
return (
|
||||
<>
|
||||
<BoxButtonOnFooter>
|
||||
<ButtonCustom isLoading={isLoading} onPress={() => handlerOnSubmit()}>
|
||||
Simpan
|
||||
</ButtonCustom>
|
||||
</BoxButtonOnFooter>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const onFocusHandler = createFocusHandler();
|
||||
|
||||
return (
|
||||
<FormWrapper footerComponent={buttonSubmit()}>
|
||||
<InformationBox text="Poster atau gambar lowongan kerja bersifat opsional, tidak wajib untuk dimasukkan dan upload lah gambar yang sesuai dengan deskripsi lowongan kerja." />
|
||||
|
||||
<LandscapeFrameUploaded image={image as string} />
|
||||
<ButtonCenteredOnly
|
||||
onPress={() => {
|
||||
pickImage({
|
||||
setImageUri: setImage,
|
||||
});
|
||||
}}
|
||||
icon="upload"
|
||||
>
|
||||
Upload
|
||||
</ButtonCenteredOnly>
|
||||
|
||||
<Spacing />
|
||||
|
||||
<View onStartShouldSetResponder={() => true}>
|
||||
<TextInputCustom
|
||||
label="Judul Lowongan"
|
||||
placeholder="Masukan Judul Lowongan Kerja"
|
||||
required
|
||||
value={data.title}
|
||||
onChangeText={(value) => setData({ ...data, title: value })}
|
||||
onFocus={onFocusHandler}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View onStartShouldSetResponder={() => true}>
|
||||
<TextAreaCustom
|
||||
label="Syarat & Kualifikasi"
|
||||
placeholder="Masukan Syarat & Kualifikasi Lowongan Kerja"
|
||||
required
|
||||
showCount
|
||||
maxLength={1000}
|
||||
value={data.content}
|
||||
onChangeText={(value) => setData({ ...data, content: value })}
|
||||
onFocus={onFocusHandler}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View onStartShouldSetResponder={() => true}>
|
||||
<TextAreaCustom
|
||||
label="Deskripsi Lowongan"
|
||||
placeholder="Masukan Deskripsi Lowongan Kerja"
|
||||
required
|
||||
showCount
|
||||
maxLength={1000}
|
||||
value={data.deskripsi}
|
||||
onChangeText={(value) => setData({ ...data, deskripsi: value })}
|
||||
onFocus={onFocusHandler}
|
||||
/>
|
||||
</View>
|
||||
</FormWrapper>
|
||||
);
|
||||
}
|
||||
209
screens/Job/ScreenJobEdit.tsx
Normal file
209
screens/Job/ScreenJobEdit.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import {
|
||||
BaseBox,
|
||||
ButtonCenteredOnly,
|
||||
ButtonCustom,
|
||||
DummyLandscapeImage,
|
||||
InformationBox,
|
||||
LandscapeFrameUploaded,
|
||||
LoaderCustom,
|
||||
NewWrapper_V2,
|
||||
Spacing,
|
||||
StackCustom,
|
||||
TextAreaCustom,
|
||||
TextInputCustom,
|
||||
} from "@/components";
|
||||
import DIRECTORY_ID from "@/constants/directory-id";
|
||||
import { apiJobGetOne, apiJobUpdateData } from "@/service/api-client/api-job";
|
||||
import {
|
||||
deleteFileService,
|
||||
uploadFileService,
|
||||
} from "@/service/upload-service";
|
||||
import pickImage from "@/utils/pickImage";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import Toast from "react-native-toast-message";
|
||||
|
||||
export function Job_ScreenEdit() {
|
||||
const { id } = useLocalSearchParams();
|
||||
const [data, setData] = useState<any>({
|
||||
title: "",
|
||||
content: "",
|
||||
deskripsi: "",
|
||||
});
|
||||
const [isLoadData, setIsLoadData] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [imageUri, setImageUri] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
onLoadData();
|
||||
}, [id]);
|
||||
|
||||
const onLoadData = async () => {
|
||||
try {
|
||||
setIsLoadData(true);
|
||||
const response = await apiJobGetOne({ id: id as string });
|
||||
if (response.success) {
|
||||
setData(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("[ERROR]", error);
|
||||
} finally {
|
||||
setIsLoadData(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlerOnUpdate = async () => {
|
||||
if (!data.title || !data.content || !data.deskripsi) {
|
||||
Toast.show({
|
||||
type: "info",
|
||||
text1: "Info",
|
||||
text2: "Harap isi semua data",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
let newImageId = "";
|
||||
|
||||
if (imageUri) {
|
||||
const responseUploadImage = await uploadFileService({
|
||||
imageUri: imageUri,
|
||||
dirId: DIRECTORY_ID.job_image,
|
||||
});
|
||||
|
||||
if (responseUploadImage.success) {
|
||||
newImageId = responseUploadImage.data.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (data?.imageId) {
|
||||
const responseDeleteImage = await deleteFileService({
|
||||
id: data.imageId,
|
||||
});
|
||||
|
||||
if (!responseDeleteImage.success) {
|
||||
console.log("[ERROR DELETE IMAGE]", responseDeleteImage.message);
|
||||
}
|
||||
}
|
||||
|
||||
const newData = {
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
deskripsi: data.deskripsi,
|
||||
imageId: newImageId,
|
||||
};
|
||||
|
||||
const response = await apiJobUpdateData({
|
||||
id: id as string,
|
||||
data: newData,
|
||||
category: "edit",
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
Toast.show({
|
||||
type: "success",
|
||||
text1: response.message,
|
||||
});
|
||||
router.back();
|
||||
} else {
|
||||
Toast.show({
|
||||
type: "info",
|
||||
text1: "Info",
|
||||
text2: response.message,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("[ERROR]", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const buttonSubmit = () => {
|
||||
return (
|
||||
<>
|
||||
<ButtonCustom isLoading={isLoading} onPress={() => handlerOnUpdate()}>
|
||||
Update
|
||||
</ButtonCustom>
|
||||
<Spacing />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<NewWrapper_V2
|
||||
enableKeyboardHandling
|
||||
keyboardScrollOffset={100}
|
||||
footerComponent={buttonSubmit()}
|
||||
>
|
||||
{isLoadData ? (
|
||||
<LoaderCustom />
|
||||
) : (
|
||||
<StackCustom gap={"xs"}>
|
||||
<InformationBox text="Poster atau gambar lowongan kerja bersifat opsional, tidak wajib untuk dimasukkan dan upload lah gambar yang sesuai dengan deskripsi lowongan kerja." />
|
||||
|
||||
{imageUri ? (
|
||||
<LandscapeFrameUploaded image={imageUri as any} />
|
||||
) : (
|
||||
<BaseBox>
|
||||
<DummyLandscapeImage imageId={data?.imageId} />
|
||||
</BaseBox>
|
||||
)}
|
||||
|
||||
<ButtonCenteredOnly
|
||||
onPress={() => {
|
||||
pickImage({
|
||||
setImageUri,
|
||||
});
|
||||
}}
|
||||
icon="upload"
|
||||
>
|
||||
Upload
|
||||
</ButtonCenteredOnly>
|
||||
|
||||
<Spacing />
|
||||
|
||||
<View onStartShouldSetResponder={() => true}>
|
||||
<TextInputCustom
|
||||
label="Judul Lowongan"
|
||||
placeholder="Masukan Judul Lowongan Kerja"
|
||||
required
|
||||
value={data.title}
|
||||
onChangeText={(value) => setData({ ...data, title: value })}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View onStartShouldSetResponder={() => true}>
|
||||
<TextAreaCustom
|
||||
label="Syarat & Kualifikasi"
|
||||
placeholder="Masukan Syarat & Kualifikasi Lowongan Kerja"
|
||||
required
|
||||
showCount
|
||||
maxLength={1000}
|
||||
value={data.content}
|
||||
onChangeText={(value) => setData({ ...data, content: value })}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View onStartShouldSetResponder={() => true}>
|
||||
<TextAreaCustom
|
||||
label="Deskripsi Lowongan"
|
||||
placeholder="Masukan Deskripsi Lowongan Kerja"
|
||||
required
|
||||
showCount
|
||||
maxLength={1000}
|
||||
value={data.deskripsi}
|
||||
onChangeText={(value) => setData({ ...data, deskripsi: value })}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{buttonSubmit()}
|
||||
</StackCustom>
|
||||
)}
|
||||
</NewWrapper_V2>
|
||||
);
|
||||
}
|
||||
207
screens/Job/ScreenJobEdit2.tsx
Normal file
207
screens/Job/ScreenJobEdit2.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import {
|
||||
BaseBox,
|
||||
BoxButtonOnFooter,
|
||||
ButtonCenteredOnly,
|
||||
ButtonCustom,
|
||||
DummyLandscapeImage,
|
||||
InformationBox,
|
||||
LandscapeFrameUploaded,
|
||||
LoaderCustom,
|
||||
NewWrapper_V2,
|
||||
Spacing,
|
||||
StackCustom,
|
||||
TextAreaCustom,
|
||||
TextInputCustom,
|
||||
} from "@/components";
|
||||
import { AccentColor } from "@/constants/color-palet";
|
||||
import DIRECTORY_ID from "@/constants/directory-id";
|
||||
import { apiJobGetOne, apiJobUpdateData } from "@/service/api-client/api-job";
|
||||
import { deleteFileService, uploadFileService } from "@/service/upload-service";
|
||||
import pickImage from "@/utils/pickImage";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import Toast from "react-native-toast-message";
|
||||
|
||||
export function Job_ScreenEdit2() {
|
||||
const { id } = useLocalSearchParams();
|
||||
const [data, setData] = useState<any>({
|
||||
title: "",
|
||||
content: "",
|
||||
deskripsi: "",
|
||||
});
|
||||
const [isLoadData, setIsLoadData] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [imageUri, setImageUri] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
onLoadData();
|
||||
}, [id]);
|
||||
|
||||
const onLoadData = async () => {
|
||||
try {
|
||||
setIsLoadData(true);
|
||||
const response = await apiJobGetOne({ id: id as string });
|
||||
if (response.success) {
|
||||
setData(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("[ERROR]", error);
|
||||
} finally {
|
||||
setIsLoadData(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlerOnUpdate = async () => {
|
||||
if (!data.title || !data.content || !data.deskripsi) {
|
||||
Toast.show({
|
||||
type: "info",
|
||||
text1: "Info",
|
||||
text2: "Harap isi semua data",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
let newImageId = "";
|
||||
|
||||
if (imageUri) {
|
||||
const responseUploadImage = await uploadFileService({
|
||||
imageUri: imageUri,
|
||||
dirId: DIRECTORY_ID.job_image,
|
||||
});
|
||||
|
||||
if (responseUploadImage.success) {
|
||||
newImageId = responseUploadImage.data.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (data?.imageId) {
|
||||
const responseDeleteImage = await deleteFileService({
|
||||
id: data.imageId,
|
||||
});
|
||||
|
||||
if (!responseDeleteImage.success) {
|
||||
console.log("[ERROR DELETE IMAGE]", responseDeleteImage.message);
|
||||
}
|
||||
}
|
||||
|
||||
const newData = {
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
deskripsi: data.deskripsi,
|
||||
imageId: newImageId,
|
||||
};
|
||||
|
||||
const response = await apiJobUpdateData({
|
||||
id: id as string,
|
||||
data: newData,
|
||||
category: "edit",
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
Toast.show({
|
||||
type: "success",
|
||||
text1: response.message,
|
||||
});
|
||||
router.back();
|
||||
} else {
|
||||
Toast.show({
|
||||
type: "info",
|
||||
text1: "Info",
|
||||
text2: response.message,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("[ERROR]", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const buttonSubmit = () => {
|
||||
return (
|
||||
<>
|
||||
<BoxButtonOnFooter>
|
||||
<ButtonCustom isLoading={isLoading} onPress={() => handlerOnUpdate()}>
|
||||
Update
|
||||
</ButtonCustom>
|
||||
</BoxButtonOnFooter>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<NewWrapper_V2
|
||||
enableKeyboardHandling
|
||||
keyboardScrollOffset={100}
|
||||
footerComponent={buttonSubmit()}
|
||||
>
|
||||
{isLoadData ? (
|
||||
<LoaderCustom />
|
||||
) : (
|
||||
<StackCustom gap={"xs"}>
|
||||
<InformationBox text="Poster atau gambar lowongan kerja bersifat opsional, tidak wajib untuk dimasukkan dan upload lah gambar yang sesuai dengan deskripsi lowongan kerja." />
|
||||
|
||||
{imageUri ? (
|
||||
<LandscapeFrameUploaded image={imageUri as any} />
|
||||
) : (
|
||||
<BaseBox>
|
||||
<DummyLandscapeImage imageId={data?.imageId} />
|
||||
</BaseBox>
|
||||
)}
|
||||
|
||||
<ButtonCenteredOnly
|
||||
onPress={() => {
|
||||
pickImage({
|
||||
setImageUri,
|
||||
});
|
||||
}}
|
||||
icon="upload"
|
||||
>
|
||||
Upload
|
||||
</ButtonCenteredOnly>
|
||||
|
||||
<Spacing />
|
||||
|
||||
<View onStartShouldSetResponder={() => true}>
|
||||
<TextInputCustom
|
||||
label="Judul Lowongan"
|
||||
placeholder="Masukan Judul Lowongan Kerja"
|
||||
required
|
||||
value={data.title}
|
||||
onChangeText={(value) => setData({ ...data, title: value })}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View onStartShouldSetResponder={() => true}>
|
||||
<TextAreaCustom
|
||||
label="Syarat & Kualifikasi"
|
||||
placeholder="Masukan Syarat & Kualifikasi Lowongan Kerja"
|
||||
required
|
||||
showCount
|
||||
maxLength={1000}
|
||||
value={data.content}
|
||||
onChangeText={(value) => setData({ ...data, content: value })}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View onStartShouldSetResponder={() => true}>
|
||||
<TextAreaCustom
|
||||
label="Deskripsi Lowongan"
|
||||
placeholder="Masukan Deskripsi Lowongan Kerja"
|
||||
required
|
||||
showCount
|
||||
maxLength={1000}
|
||||
value={data.deskripsi}
|
||||
onChangeText={(value) => setData({ ...data, deskripsi: value })}
|
||||
/>
|
||||
</View>
|
||||
</StackCustom>
|
||||
)}
|
||||
</NewWrapper_V2>
|
||||
);
|
||||
}
|
||||
309
tasks/TASK-004-newwrapper-migration.md
Normal file
309
tasks/TASK-004-newwrapper-migration.md
Normal file
@@ -0,0 +1,309 @@
|
||||
# TASK-004: NewWrapper_V2 Gradual Migration
|
||||
|
||||
## 📋 Deskripsi
|
||||
|
||||
Migrasi bertahap dari `NewWrapper` ke `NewWrapper_V2` untuk memperbaiki bug keyboard handling (footer terangkat, area putih, input terpotong).
|
||||
|
||||
## 🎯 Tujuan
|
||||
|
||||
1. Replace `NewWrapper` → `NewWrapper_V2` secara bertahap
|
||||
2. Fix keyboard handling issues di semua form screens
|
||||
3. Zero breaking changes dengan gradual migration
|
||||
4. Clean up test components yang tidak diperlukan
|
||||
|
||||
---
|
||||
|
||||
## 📊 Migration Priority
|
||||
|
||||
### **Phase 1: Job Screens** (Week 1) - CURRENT
|
||||
- [x] `screens/Job/ScreenJobCreate2.tsx` → Already using keyboard handling
|
||||
- [ ] `screens/Job/ScreenJobCreate.tsx` → Migrate to NewWrapper_V2
|
||||
- [ ] `screens/Job/ScreenJobEdit.tsx` → Migrate to NewWrapper_V2
|
||||
- [ ] Delete test files after migration
|
||||
|
||||
### **Phase 2: Event & Profile Screens** (Week 2)
|
||||
- [ ] `screens/Event/ScreenEventCreate.tsx`
|
||||
- [ ] `screens/Event/ScreenEventEdit.tsx`
|
||||
- [ ] `screens/Profile/ScreenProfileCreate.tsx`
|
||||
- [ ] `screens/Profile/ScreenProfileEdit.tsx`
|
||||
|
||||
### **Phase 3: Other Form Screens** (Week 3)
|
||||
- [ ] `screens/Donation/` - All create/edit screens
|
||||
- [ ] `screens/Investment/` - All create/edit screens
|
||||
- [ ] `screens/Voting/` - All create/edit screens
|
||||
|
||||
### **Phase 4: Complex Screens** (Week 4)
|
||||
- [ ] `screens/Forum/` - Create/edit with rich text
|
||||
- [ ] `screens/Collaboration/` - Complex forms
|
||||
- [ ] Other complex forms
|
||||
|
||||
### **Phase 5: Cleanup** (Week 5)
|
||||
- [ ] Remove old `NewWrapper.tsx` (or deprecate)
|
||||
- [ ] Rename `NewWrapper_V2.tsx` → `NewWrapper.tsx`
|
||||
- [ ] Update documentation
|
||||
- [ ] Delete test components
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Task Details
|
||||
|
||||
### **Task 4.1: Job Screens Migration** ✅ IN PROGRESS
|
||||
|
||||
**Files to migrate:**
|
||||
1. `screens/Job/ScreenJobCreate.tsx`
|
||||
2. `screens/Job/ScreenJobEdit.tsx`
|
||||
|
||||
**Changes per file:**
|
||||
```typescript
|
||||
// BEFORE
|
||||
import { NewWrapper } from "@/components";
|
||||
|
||||
<NewWrapper footerComponent={buttonSubmit()}>
|
||||
<TextInputCustom label="..." />
|
||||
</NewWrapper>
|
||||
|
||||
// AFTER
|
||||
import { NewWrapper_V2 } from "@/components";
|
||||
|
||||
<NewWrapper_V2
|
||||
enableKeyboardHandling
|
||||
keyboardScrollOffset={100}
|
||||
footerComponent={buttonSubmit()}
|
||||
>
|
||||
<View onStartShouldSetResponder={() => true}>
|
||||
<TextInputCustom label="..." />
|
||||
</View>
|
||||
</NewWrapper_V2>
|
||||
```
|
||||
|
||||
**Checklist per screen:**
|
||||
- [ ] Replace `NewWrapper` → `NewWrapper_V2`
|
||||
- [ ] Add `enableKeyboardHandling` prop
|
||||
- [ ] Wrap all TextInput/TextArea with `View onStartShouldSetResponder`
|
||||
- [ ] Test on Android (navigation buttons)
|
||||
- [ ] Test on Android (gesture)
|
||||
- [ ] Test on iOS
|
||||
- [ ] Verify auto-scroll works
|
||||
- [ ] Verify footer stays in place
|
||||
- [ ] Verify no white area
|
||||
|
||||
**Cleanup after migration:**
|
||||
- [ ] Delete `screens/Job/ScreenJobCreate2.tsx` (test file)
|
||||
- [ ] Delete `screens/Job/ScreenJobEdit2.tsx` (test file)
|
||||
- [ ] Update app routes if needed
|
||||
|
||||
---
|
||||
|
||||
### **Task 4.2: Delete Test Components**
|
||||
|
||||
**Files to delete:**
|
||||
- [ ] `components/_ShareComponent/TestWrapper.tsx`
|
||||
- [ ] `components/_ShareComponent/TestKeyboardInput.tsx`
|
||||
- [ ] `app/(application)/(user)/test-keyboard.tsx`
|
||||
- [ ] `app/(application)/(user)/test-keyboard-bug.tsx`
|
||||
|
||||
**Keep (useful):**
|
||||
- ✅ `components/_ShareComponent/FormWrapper.tsx` (alternative wrapper)
|
||||
- ✅ `hooks/useKeyboardForm.ts` (keyboard hook)
|
||||
- ✅ `docs/KEYBOARD-BUG-TEST.md` (documentation)
|
||||
- ✅ `docs/NEWWRAPPER-KEYBOARD-IMPLEMENTATION.md` (documentation)
|
||||
|
||||
---
|
||||
|
||||
### **Task 4.3: Update Documentation**
|
||||
|
||||
**Files to update:**
|
||||
- [ ] `QWEN.md` - Update NewWrapper_V2 usage
|
||||
- [ ] `docs/NEWWRAPPER-KEYBOARD-IMPLEMENTATION.md` - Mark as completed
|
||||
- [ ] Create migration guide for team
|
||||
|
||||
---
|
||||
|
||||
## 📝 Migration Guide (Per Screen)
|
||||
|
||||
### **Step 1: Import NewWrapper_V2**
|
||||
|
||||
```typescript
|
||||
// Change this:
|
||||
import { NewWrapper } from "@/components";
|
||||
|
||||
// To this:
|
||||
import { NewWrapper_V2 } from "@/components";
|
||||
```
|
||||
|
||||
### **Step 2: Update Component Usage**
|
||||
|
||||
```typescript
|
||||
// Change this:
|
||||
<NewWrapper footerComponent={buttonSubmit()}>
|
||||
<StackCustom>
|
||||
<TextInputCustom label="Judul" ... />
|
||||
<TextAreaCustom label="Deskripsi" ... />
|
||||
</StackCustom>
|
||||
</NewWrapper>
|
||||
|
||||
// To this:
|
||||
<NewWrapper_V2
|
||||
enableKeyboardHandling
|
||||
keyboardScrollOffset={100}
|
||||
footerComponent={buttonSubmit()}
|
||||
>
|
||||
<StackCustom>
|
||||
<View onStartShouldSetResponder={() => true}>
|
||||
<TextInputCustom label="Judul" ... />
|
||||
</View>
|
||||
<View onStartShouldSetResponder={() => true}>
|
||||
<TextAreaCustom label="Deskripsi" ... />
|
||||
</View>
|
||||
</StackCustom>
|
||||
</NewWrapper_V2>
|
||||
```
|
||||
|
||||
### **Step 3: Import View**
|
||||
|
||||
```typescript
|
||||
// Add this import if not already present:
|
||||
import { View } from "react-native";
|
||||
```
|
||||
|
||||
### **Step 4: Test**
|
||||
|
||||
1. Run app
|
||||
2. Navigate to screen
|
||||
3. Tap each input field
|
||||
4. Verify auto-scroll works
|
||||
5. Verify footer stays in place
|
||||
6. Verify no white area
|
||||
7. Test submit functionality
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing Checklist
|
||||
|
||||
### **For Each Migrated Screen:**
|
||||
|
||||
**Functional Tests:**
|
||||
- [ ] All inputs focus correctly
|
||||
- [ ] Keyboard shows when tapping input
|
||||
- [ ] Auto-scroll to focused input
|
||||
- [ ] Keyboard dismisses when tapping outside
|
||||
- [ ] Footer stays at bottom
|
||||
- [ ] No white area at bottom
|
||||
- [ ] Submit button works
|
||||
- [ ] Form validation works
|
||||
- [ ] Data saves correctly
|
||||
|
||||
**Platform Tests:**
|
||||
- [ ] Android with navigation buttons
|
||||
- [ ] Android with gesture navigation
|
||||
- [ ] iOS with home button
|
||||
- [ ] iOS with gesture (notch)
|
||||
- [ ] Different screen sizes
|
||||
|
||||
**Edge Cases:**
|
||||
- [ ] Multiple inputs on screen
|
||||
- [ ] Long content (scrollable)
|
||||
- [ ] Loading state
|
||||
- [ ] Error state
|
||||
- [ ] Keyboard transition smooth
|
||||
|
||||
---
|
||||
|
||||
## 📊 Progress Tracking
|
||||
|
||||
| Phase | Screens | Status | Completed Date |
|
||||
|-------|---------|--------|----------------|
|
||||
| **Phase 1: Job** | 2 screens | 🟡 In Progress | - |
|
||||
| **Phase 2: Event & Profile** | 4 screens | ⏳ Pending | - |
|
||||
| **Phase 3: Forms** | 6-8 screens | ⏳ Pending | - |
|
||||
| **Phase 4: Complex** | 4-6 screens | ⏳ Pending | - |
|
||||
| **Phase 5: Cleanup** | Cleanup | ⏳ Pending | - |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Current Status
|
||||
|
||||
**Status**: 🟡 IN PROGRESS
|
||||
**Current Phase**: Phase 1 - Job Screens
|
||||
**Started**: 2026-04-01
|
||||
**ETA**: 2026-04-07 (Phase 1 complete)
|
||||
|
||||
---
|
||||
|
||||
## 📞 Next Actions
|
||||
|
||||
1. **Immediate** (Today):
|
||||
- [ ] Migrate `ScreenJobCreate.tsx`
|
||||
- [ ] Migrate `ScreenJobEdit.tsx`
|
||||
- [ ] Test both screens
|
||||
|
||||
2. **This Week**:
|
||||
- [ ] Delete test files
|
||||
- [ ] Document any issues
|
||||
- [ ] Prepare Phase 2
|
||||
|
||||
3. **Next Week**:
|
||||
- [ ] Start Phase 2 (Event & Profile)
|
||||
- [ ] Review Phase 1 results
|
||||
- [ ] Adjust migration guide if needed
|
||||
|
||||
---
|
||||
|
||||
## 📚 Related Files
|
||||
|
||||
**Components:**
|
||||
- `components/_ShareComponent/NewWrapper.tsx` (Old)
|
||||
- `components/_ShareComponent/NewWrapper_V2.tsx` (New)
|
||||
- `hooks/useKeyboardForm.ts` (Keyboard hook)
|
||||
|
||||
**Documentation:**
|
||||
- `docs/NEWWRAPPER-KEYBOARD-IMPLEMENTATION.md` (Full analysis)
|
||||
- `docs/KEYBOARD-BUG-TEST.md` (Bug investigation)
|
||||
- `tasks/TASK-004-newwrapper-migration.md` (This file)
|
||||
|
||||
**Screens to Migrate:**
|
||||
- `screens/Job/ScreenJobCreate.tsx`
|
||||
- `screens/Job/ScreenJobEdit.tsx`
|
||||
- (More in subsequent phases)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Risk Mitigation
|
||||
|
||||
**If issues found during migration:**
|
||||
|
||||
1. **Stop migration** for that screen
|
||||
2. **Revert changes** if critical bug
|
||||
3. **Document issue** in detail
|
||||
4. **Fix NewWrapper_V2** if needed
|
||||
5. **Resume migration** after fix
|
||||
|
||||
**Rollback plan:**
|
||||
- Keep old `NewWrapper` until all screens migrated
|
||||
- Easy to revert per screen
|
||||
- No breaking changes to other screens
|
||||
|
||||
---
|
||||
|
||||
## ✅ Success Criteria
|
||||
|
||||
**Phase 1 Complete when:**
|
||||
- [ ] Job Create migrated
|
||||
- [ ] Job Edit migrated
|
||||
- [ ] Both screens tested on iOS & Android
|
||||
- [ ] No critical bugs
|
||||
- [ ] Test files deleted
|
||||
- [ ] Documentation updated
|
||||
|
||||
**Overall Migration Complete when:**
|
||||
- [ ] All form screens migrated
|
||||
- [ ] All screens tested
|
||||
- [ ] Old NewWrapper deprecated/removed
|
||||
- [ ] Team trained on NewWrapper_V2
|
||||
- [ ] Documentation complete
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2026-04-01
|
||||
**Created by**: AI Assistant
|
||||
**Status**: 🟡 IN PROGRESS
|
||||
Reference in New Issue
Block a user