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 { Job_ScreenEdit } from "@/screens/Job/ScreenJobEdit";
|
||||||
import {
|
import { Job_ScreenEdit2 } from "@/screens/Job/ScreenJobEdit2";
|
||||||
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";
|
|
||||||
|
|
||||||
export default function JobEdit() {
|
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 (
|
return (
|
||||||
<ViewWrapper>
|
<>
|
||||||
{isLoadData ? (
|
{/* <Job_ScreenEdit />; */}
|
||||||
<LoaderCustom />
|
<Job_ScreenEdit2 />
|
||||||
) : (
|
</>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,168 +1,11 @@
|
|||||||
import {
|
import { Job_ScreenCreate } from "@/screens/Job/ScreenJobCreate";
|
||||||
BoxButtonOnFooter,
|
import { Job_ScreenCreate2 } from "@/screens/Job/ScreenJobCreate2";
|
||||||
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";
|
|
||||||
|
|
||||||
export default function JobCreate() {
|
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 (
|
return (
|
||||||
<NewWrapper footerComponent={buttonSubmit()}>
|
<>
|
||||||
<StackCustom gap={"xs"}>
|
{/* <Job_ScreenCreate /> */}
|
||||||
<InformationBox text="Poster atau gambar lowongan kerja bersifat opsional, tidak wajib untuk dimasukkan dan upload lah gambar yang sesuai dengan deskripsi lowongan kerja." />
|
<Job_ScreenCreate2/>
|
||||||
|
</>
|
||||||
{/* <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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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}
|
ListHeaderComponent={listProps.ListHeaderComponent}
|
||||||
ListFooterComponent={listProps.ListFooterComponent}
|
ListFooterComponent={listProps.ListFooterComponent}
|
||||||
ListEmptyComponent={listProps.ListEmptyComponent}
|
ListEmptyComponent={listProps.ListEmptyComponent}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
paddingBottom: footerComponent && !hideFooter ? OS_HEIGHT : 0
|
paddingBottom: footerComponent && !hideFooter ? OS_HEIGHT : 0
|
||||||
}}
|
}}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Footer dengan position absolute untuk stay di bawah */}
|
{/* Footer - tetap di bawah dengan position absolute */}
|
||||||
{footerComponent && !hideFooter && (
|
{footerComponent && !hideFooter && (
|
||||||
<View style={styles.footerContainer}>
|
<View style={styles.footerContainer}>
|
||||||
<SafeAreaView
|
<SafeAreaView
|
||||||
@@ -133,7 +133,7 @@ const NewWrapper = (props: NewWrapperProps) => {
|
|||||||
style={{ backgroundColor: MainColor.darkblue }}
|
style={{ backgroundColor: MainColor.darkblue }}
|
||||||
>
|
>
|
||||||
{footerComponent}
|
{footerComponent}
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -163,11 +163,11 @@ const NewWrapper = (props: NewWrapperProps) => {
|
|||||||
<View style={GStyles.stickyHeader}>{headerComponent}</View>
|
<View style={GStyles.stickyHeader}>{headerComponent}</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View style={{ flex: 0 }} collapsable={false}>
|
<View style={{ flex: 1 }}>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
paddingBottom: footerComponent && !hideFooter ? OS_HEIGHT : 0
|
paddingBottom: footerComponent && !hideFooter ? OS_HEIGHT : 0
|
||||||
}}
|
}}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
refreshControl={refreshControl}
|
refreshControl={refreshControl}
|
||||||
@@ -178,7 +178,7 @@ const NewWrapper = (props: NewWrapperProps) => {
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Footer dengan position absolute untuk stay di bawah */}
|
{/* Footer - tetap di bawah dengan position absolute */}
|
||||||
{footerComponent && !hideFooter && (
|
{footerComponent && !hideFooter && (
|
||||||
<View style={styles.footerContainer}>
|
<View style={styles.footerContainer}>
|
||||||
<SafeAreaView
|
<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 GridComponentView from "./_ShareComponent/GridSectionView";
|
||||||
import NewWrapper from "./_ShareComponent/NewWrapper";
|
import NewWrapper from "./_ShareComponent/NewWrapper";
|
||||||
import BasicWrapper from "./_ShareComponent/BasicWrapper";
|
import BasicWrapper from "./_ShareComponent/BasicWrapper";
|
||||||
|
import { TestWrapper } from "./_ShareComponent/TestWrapper";
|
||||||
|
import { FormWrapper } from "./_ShareComponent/FormWrapper";
|
||||||
|
import { NewWrapper_V2 } from "./_ShareComponent/NewWrapper_V2";
|
||||||
|
|
||||||
// Progress
|
// Progress
|
||||||
import ProgressCustom from "./Progress/ProgressCustom";
|
import ProgressCustom from "./Progress/ProgressCustom";
|
||||||
// Loader
|
// Loader
|
||||||
@@ -127,6 +131,9 @@ export {
|
|||||||
Spacing,
|
Spacing,
|
||||||
NewWrapper,
|
NewWrapper,
|
||||||
BasicWrapper,
|
BasicWrapper,
|
||||||
|
TestWrapper,
|
||||||
|
FormWrapper,
|
||||||
|
NewWrapper_V2,
|
||||||
// Stack
|
// Stack
|
||||||
StackCustom,
|
StackCustom,
|
||||||
TabBarBackground,
|
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