Compare commits

...

19 Commits

Author SHA1 Message Date
b34bc3799e feat: Add default contentPadding to NewWrapper_V2
- Set default contentPadding to 16px for consistent spacing
- Added contentPadding prop to BaseProps interface
- Updated documentation in TASK-004

Benefits:
- All screens automatically have 16px padding
- Cleaner code (no need to specify padding everywhere)
- Still customizable (set to 0 or custom value if needed)
- Box content not too tight to screen edges

Phase 1 completed with consistent padding!

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-02 17:16:19 +08:00
7cb4f30ae9 refactor: Replace NewWrapper with NewWrapper_V2 for all Job screens
- ScreenBeranda2.tsx: NewWrapper → NewWrapper_V2
- ScreenArchive2.tsx: NewWrapper → NewWrapper_V2
- MainViewStatus2.tsx: NewWrapper → NewWrapper_V2

All Job screens now using NewWrapper_V2 with:
- Better keyboard handling
- Footer width 100%
- Content padding bottom 80px
- Auto-scroll to focused input (for forms)
- No white area when keyboard close

Phase 1 completed: All Job screens migrated!

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-02 16:13:23 +08:00
0f552443c4 refactor: Cleanup test files and migrate Job Detail screen
- Delete ScreenJobCreate2.tsx and ScreenJobEdit2.tsx (test files)
- Delete TestWrapper.tsx and TestKeyboardInput.tsx (test components)
- Delete test pages (test-keyboard.tsx, test-keyboard-bug.tsx)
- Update create.tsx to use ScreenJobCreate (not test version)
- Update edit.tsx to use ScreenJobEdit (not test version)
- Migrate Job Detail screen to NewWrapper_V2
- Remove TestWrapper from exports
- Clean up imports

Phase 1 cleanup completed!

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-02 15:31:26 +08:00
90bc8ae343 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
2026-04-02 15:07:10 +08:00
98f8c7e2bf Fix layout tabs pada komponen
Fix home tabs

### No Issue
2026-04-01 17:17:12 +08:00
81bbd8e6b0 Clean project and release with new domain: hipmi.muku.id
### No issue
2026-03-31 17:07:27 +08:00
57159d2c45 update app config domain
### No Issue
2026-03-31 14:29:19 +08:00
66373fa65b Clean code
Update android domain

### No Issue
2026-03-31 14:28:43 +08:00
6d545f2af9 Fix change yang tertinggal 2026-03-30 17:39:53 +08:00
eeb95336f2 Fix version & tabs android
### No Issue
2026-03-30 17:38:31 +08:00
6fb3b229c3 Fix andorid 2026-03-27 17:59:13 +08:00
76deec9c53 Fix tabs agar bisa di klik
### Issue: Di android batas atas kurang turun
2026-03-26 17:37:31 +08:00
31948f71db Fix: NewWrapper footer position dengan SafeAreaView
Problem:
- Footer component tertutup atau scroll melebihi layar
- Tabs tidak bisa diklik karena footer floating
- Height OS_HEIGHT tidak konsisten di berbagai device

Solution:
 Footer menggunakan position: absolute untuk stay di bawah
 Konten ScrollView/FlatList mendapat paddingBottom: OS_HEIGHT
 SafeAreaView hanya untuk area aman, bukan height control
 Footer tetap visible di semua ukuran layar

Changes:
- Add styles.footerContainer dengan position: absolute
- Add paddingBottom ke contentContainerStyle (ScrollView & FlatList)
- Remove height: OS_HEIGHT dari SafeAreaView
- Footer stay di bottom dengan proper safe area handling

Result:
- Footer selalu terlihat di bawah layar
- Konten tidak tertutup footer (ada padding)
- Tabs bisa diklik dengan baik
- Konsisten di iOS & Android
- Respect safe area insets

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-26 12:28:07 +08:00
16decd89c8 Refactor: apply PhoneInputCustom to PortofolioEdit
Changes:
- Replace react-native-international-phone-number with PhoneInputCustom
- Remove ICountry dependency, use CountryData from constants
- Add phone number state management
- Implement country detection from existing phone number
- Auto-detect country based on calling code on load
- Improve phone number formatting logic

Features Applied:
 NO emoji flags - only calling codes (+62, +65, etc)
 Clean, professional UI
 Modal country picker with search
 Auto-detect country from saved phone number
 Real-time phone number formatting
 Auto-update country code on change
 Consistent with LoginView & ScreenPortofolioCreate

Phone Detection Logic:
- Load existing phone number from API
- Detect country by matching calling code
- Extract phone number without country code for display
- Set detected country for country picker
- Re-format on country change

UI:
- Phone Input: [+62 ⌄ | xxx-xxx-xxx]
- Country Picker: Modal with search
- Display: Country name + calling code only

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-26 12:17:05 +08:00
ecbcc12abf Refactor: apply PhoneInputCustom to ScreenPortofolioCreate
Changes:
- Replace react-native-international-phone-number with PhoneInputCustom
- Remove ICountry dependency, use CountryData from constants
- Update state management (inputValue → phoneNumber)
- Improve phone number formatting logic
- Add handleCountryChange for better country switching

Features Applied:
 NO emoji flags - only calling codes (+62, +65, etc)
 Clean, professional UI
 Modal country picker with search
 Real-time phone number formatting
 Auto-update country code on change
 Consistent with LoginView implementation

Phone Input Logic:
- Format on every phone change
- Re-format when country changes
- Remove duplicate country codes
- Remove leading zeros
- Store E.164 format in API data

UI:
- Phone Input: [+62 ⌄ | xxx-xxx-xxx]
- Country Picker: Modal with search
- Display: Country name + calling code only

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-26 11:42:54 +08:00
0cb734e790 Refactor: apply PhoneInputCustom to LoginView
Changes:
- Replace react-native-international-phone-number with PhoneInputCustom
- Remove dependency on ICountry, use CountryData from constants
- Add KeyboardAvoidingView for better iOS keyboard handling
- Improve validation with libphonenumber-js
- Add proper phone number formatting
- Update state management (inputValue → phoneNumber)

Features Applied:
 NO emoji flags - only calling codes (+62, +65, etc)
 Clean, professional UI
 Modal country picker with search
 Better keyboard handling on iOS
 Real-time validation with libphonenumber-js
 Auto-formatting for international numbers
 Reusable component

UI:
- Phone Input: [+62 ⌄ | 812-3456-7890]
- Country Picker: Modal with search
- Display: Country name + calling code only

Validation:
- Check phone number length
- Validate with libphonenumber-js
- Format to E.164 on login
- Error handling with Toast

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-26 11:33:18 +08:00
0d2fef1878 Feat: add reusable PhoneInput component without flags
New Components:
- PhoneInputCustom: Reusable phone input without emoji flags
- constants/countries.ts: Country data with calling codes only

Features:
 NO emoji flags - only country name + calling code (+62, +65, etc)
 Clean, professional UI
 Modal country picker with search
 15 countries supported
 Helper functions: getCountryByCallingCode, getCountryByCode, searchCountries
 Fully typed with TypeScript
 Reusable across the app
 Maximum compatibility (no emoji rendering issues)

UI Design:
- Phone Input: [+62 ⌄ | 812-3456-7890]
- Country Picker: Modal with search
- Display: Country name + calling code only

Usage:
import { PhoneInputCustom } from '@/components';
import { DEFAULT_COUNTRY } from '@/constants/countries';

<PhoneInputCustom
  value={phoneNumber}
  onChangePhoneNumber={setPhoneNumber}
  selectedCountry={selectedCountry}
  onChangeCountry={setSelectedCountry}
/>

Benefits:
 Works on ALL iOS versions (no emoji issues)
 Consistent across all platforms
 Faster render (no emoji/image loading)
 Cleaner code structure
 Easy to maintain

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-26 11:27:59 +08:00
2e58f8c7b4 Docs: add task breakdown untuk fix phone input iOS 16+
- Add TASKS/fix-phone-input-ios-16.md dengan analisis lengkap
- 3 opsi solusi: KeyboardAvoidingView, Keyboard Controller, Custom Input
- Detailed implementation guidelines
- Testing checklist untuk iOS 16, 17, 18

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-25 16:39:48 +08:00
b6cd308b0b Refactor pagination implementation dan perbaikan UI
- Add default page parameter di apiAllUser
- Refactor MainView_V2.tsx dengan separate render functions
- Update pagination pageSize menjadi 10 di Forum
- Fix iOS height constant dan tab styling
- Rename Admin_ScreenPortofolioCreate ke ScreenPortofolioCreate
- Add TASKS documentation folder

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-25 16:22:54 +08:00
68 changed files with 3847 additions and 2001 deletions

3
.gitignore vendored
View File

@@ -81,4 +81,7 @@ yarn-error.*
# typescript
*.tsbuildinfo
# secrets
secrets/
# @end expo-cli

8
.qwen/settings.json Normal file
View File

@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(git add *)"
]
},
"$version": 3
}

7
.qwen/settings.json.orig Normal file
View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(git add *)"
]
}
}

View File

@@ -100,7 +100,7 @@ packagingOptions {
applicationId 'com.bip.hipmimobileapp'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionCode 5
versionName "1.0.2"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
</manifest>

View File

@@ -37,7 +37,7 @@
</intent-filter>
<intent-filter android:autoVerify="true" data-generated="true">
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="https" android:host="cld-dkr-hipmi-stg.wibudev.com" android:pathPrefix="/"/>
<data android:scheme="https" android:host="hipmi.muku.id" android:pathPrefix="/"/>
<category android:name="android.intent.category.BROWSABLE"/>
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>

View File

@@ -6,32 +6,17 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.google.gms:google-services:4.4.1'
classpath 'com.google.gms:google-services:4.4.1'
classpath('com.android.tools.build:gradle')
classpath('com.facebook.react:react-native-gradle-plugin')
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
}
}
allprojects {
repositories {
google()
mavenCentral()
maven { url 'https://www.jitpack.io' }
}
}
apply plugin: "expo-root-project"
apply plugin: "com.facebook.react.rootproject"
// @generated begin @rnmapbox/maps-v2-maven - expo prebuild (DO NOT MODIFY) sync-d4ccbfdff48fdba3138b02a8ba41b9722af001d8
allprojects {
repositories {
maven {
url 'https://api.mapbox.com/downloads/v2/releases/maven'
// Authentication is no longer required as per Mapbox's removal of download token requirement
// See: https://github.com/mapbox/mapbox-maps-flutter/issues/775
// Keeping this as optional for backward compatibility
def token = project.properties['MAPBOX_DOWNLOADS_TOKEN'] ?: System.getenv('RNMAPBOX_MAPS_DOWNLOAD_TOKEN')
if (token) {
authentication { basic(BasicAuthentication) }
@@ -41,7 +26,11 @@ allprojects {
}
}
}
google()
mavenCentral()
maven { url 'https://www.jitpack.io' }
}
}
// @generated end @rnmapbox/maps-v2-maven
apply plugin: "expo-root-project"
apply plugin: "com.facebook.react.rootproject"

View File

@@ -25,27 +25,27 @@ export default {
ios: {
supportsTablet: true,
bundleIdentifier: "com.anonymous.hipmi-mobile",
googleServicesFile: "./ios/HIPMIBadungConnect/GoogleService-Info.plist",
googleServicesFile: "./secrets/GoogleService-Info.plist",
infoPlist: {
ITSAppUsesNonExemptEncryption: false,
NSLocationWhenInUseUsageDescription:
"Aplikasi membutuhkan akses lokasi untuk menampilkan peta.",
},
associatedDomains: [
"applinks:cld-dkr-hipmi-stg.wibudev.com",
"applinks:hipmi.muku.id",
],
buildNumber: "5",
buildNumber: "7",
},
android: {
googleServicesFile: "./google-services.json",
googleServicesFile: "./secrets/google-services.json",
adaptiveIcon: {
foregroundImage: "./assets/images/splash-icon.png",
backgroundColor: "#ffffff",
},
edgeToEdgeEnabled: true,
package: "com.bip.hipmimobileapp",
versionCode: 1,
versionCode: 5,
// softwareKeyboardLayoutMode: 'resize', // option: untuk mengatur keyboard pada room chst collaboration
intentFilters: [
{
@@ -54,7 +54,7 @@ export default {
data: [
{
scheme: "https",
host: "cld-dkr-hipmi-stg.wibudev.com",
host: "hipmi.muku.id",
pathPrefix: "/",
},
],
@@ -70,6 +70,7 @@ export default {
},
plugins: [
"./plugins/withCustomConfig",
"expo-router",
"expo-web-browser",
[

View File

@@ -2,35 +2,64 @@ import { IconHome } from "@/components/_Icon";
import { TabsStyles } from "@/styles/tabs-styles";
import { Ionicons } from "@expo/vector-icons";
import { Tabs } from "expo-router";
import { View } from "react-native";
import { MainColor } from "@/constants/color-palet";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Platform } from "react-native";
function CollaborationTabsWrapper() {
const insets = useSafeAreaInsets();
const paddingBottom = Platform.OS === "android" ? insets.bottom : 0;
export default function CollaborationTabsLayout() {
return (
<Tabs screenOptions={TabsStyles}>
<Tabs.Screen
name="index"
options={{
title: "Beranda",
tabBarIcon: ({ color }) => <IconHome color={color} />,
<View style={{ flex: 1, backgroundColor: MainColor.darkblue }}>
<Tabs
screenOptions={{
...TabsStyles,
tabBarStyle: Platform.select({
ios: {
borderTopWidth: 0,
paddingTop: 12,
height: 80,
},
android: {
borderTopWidth: 0,
paddingTop: 5,
height: 70 + paddingBottom,
},
}),
}}
/>
<Tabs.Screen
name="participant"
options={{
title: "Partisipan",
tabBarIcon: ({ color }) => (
<Ionicons size={20} name="people" color={color} />
),
}}
/>
<Tabs.Screen
name="group"
options={{
title: "Grup",
tabBarIcon: ({ color }) => (
<Ionicons size={20} name="chatbox-ellipses" color={color} />
),
}}
/>
</Tabs>
>
<Tabs.Screen
name="index"
options={{
title: "Beranda",
tabBarIcon: ({ color }) => <IconHome color={color} />,
}}
/>
<Tabs.Screen
name="participant"
options={{
title: "Partisipan",
tabBarIcon: ({ color }) => (
<Ionicons size={20} name="people" color={color} />
),
}}
/>
<Tabs.Screen
name="group"
options={{
title: "Grup",
tabBarIcon: ({ color }) => (
<Ionicons size={20} name="chatbox-ellipses" color={color} />
),
}}
/>
</Tabs>
</View>
);
}
export default function CollaborationTabsLayout() {
return <CollaborationTabsWrapper />;
}

View File

@@ -5,33 +5,62 @@ import {
FontAwesome5
} from "@expo/vector-icons";
import { Tabs } from "expo-router";
import { View } from "react-native";
import { MainColor } from "@/constants/color-palet";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Platform } from "react-native";
function DonationTabsWrapper() {
const insets = useSafeAreaInsets();
const paddingBottom = Platform.OS === "android" ? insets.bottom : 0;
export default function InvestmentTabsLayout() {
return (
<Tabs screenOptions={TabsStyles}>
<Tabs.Screen
name="index"
options={{
title: "Beranda",
tabBarIcon: ({ color }) => <IconHome color={color} />,
<View style={{ flex: 1, backgroundColor: MainColor.darkblue }}>
<Tabs
screenOptions={{
...TabsStyles,
tabBarStyle: Platform.select({
ios: {
borderTopWidth: 0,
paddingTop: 12,
height: 80,
},
android: {
borderTopWidth: 0,
paddingTop: 5,
height: 70 + paddingBottom,
},
}),
}}
/>
<Tabs.Screen
name="status"
options={{
title: "Galang Dana",
tabBarIcon: ({ color }) => <IconStatus color={color} />,
}}
/>
<Tabs.Screen
name="my-donation"
options={{
title: "Donasi Saya",
tabBarIcon: ({ color }) => (
<FontAwesome5 name="donate" color={color} size={ICON_SIZE_SMALL} />
),
}}
/>
</Tabs>
>
<Tabs.Screen
name="index"
options={{
title: "Beranda",
tabBarIcon: ({ color }) => <IconHome color={color} />,
}}
/>
<Tabs.Screen
name="status"
options={{
title: "Galang Dana",
tabBarIcon: ({ color }) => <IconStatus color={color} />,
}}
/>
<Tabs.Screen
name="my-donation"
options={{
title: "Donasi Saya",
tabBarIcon: ({ color }) => (
<FontAwesome5 name="donate" color={color} size={ICON_SIZE_SMALL} />
),
}}
/>
</Tabs>
</View>
);
}
export default function DonationTabsLayout() {
return <DonationTabsWrapper />;
}

View File

@@ -185,7 +185,6 @@ export default function DonationEdit() {
return (
<NewWrapper
hideFooter
footerComponent={
<BoxButtonOnFooter>
<ButtonCustom

View File

@@ -114,7 +114,6 @@ export default function DonationCreateStory() {
return (
<NewWrapper
hideFooter
footerComponent={
<>
<BoxButtonOnFooter>

View File

@@ -127,7 +127,6 @@ export default function DonationCreate() {
return (
<NewWrapper
hideFooter
footerComponent={
<>
<BoxButtonOnFooter>

View File

@@ -8,58 +8,83 @@ import AppHeader from "@/components/_ShareComponent/AppHeader";
import BackButtonFromNotification from "@/components/Button/BackButtonFromNotification";
import { TabsStyles } from "@/styles/tabs-styles";
import { router, Tabs, useLocalSearchParams } from "expo-router";
import { View } from "react-native";
import { MainColor } from "@/constants/color-palet";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Platform } from "react-native";
import { OS_ANDROID_HEIGHT, OS_IOS_HEIGHT } from "@/constants/constans-value";
export default function EventTabsLayout() {
function EventTabsWrapper() {
const insets = useSafeAreaInsets();
const paddingBottom = Platform.OS === "android" ? insets.bottom : 0;
const { from, category } = useLocalSearchParams<{
from?: string;
category?: string;
}>();
return (
<Tabs
screenOptions={{
...TabsStyles,
header: () => (
<AppHeader
title="Event"
left={
<BackButtonFromNotification
from={from as string}
category={category as string}
/>
}
/>
),
}}
>
<Tabs.Screen
name="index"
options={{
title: "Beranda",
tabBarIcon: ({ color }) => <IconHome color={color} />,
<View style={{ flex: 1, backgroundColor: MainColor.darkblue }}>
<Tabs
screenOptions={{
...TabsStyles,
tabBarStyle: Platform.select({
ios: {
borderTopWidth: 0,
paddingTop: 12,
height: OS_IOS_HEIGHT,
},
android: {
borderTopWidth: 0,
paddingTop: 5,
height: OS_ANDROID_HEIGHT + paddingBottom,
},
}),
header: () => (
<AppHeader
title="Event"
left={
<BackButtonFromNotification
from={from || ""}
category={category}
/>
}
/>
),
}}
/>
<Tabs.Screen
name="status"
options={{
title: "Status",
tabBarIcon: ({ color }) => <IconStatus color={color} />,
}}
/>
<Tabs.Screen
name="contribution"
options={{
title: "Kontribusi",
tabBarIcon: ({ color }) => <IconContribution color={color} />,
}}
/>
<Tabs.Screen
name="history"
options={{
title: "Riwayat",
tabBarIcon: ({ color }) => <IconHistory color={color} />,
}}
/>
</Tabs>
>
<Tabs.Screen
name="index"
options={{
title: "Beranda",
tabBarIcon: ({ color }) => <IconHome color={color} />,
}}
/>
<Tabs.Screen
name="status"
options={{
title: "Status",
tabBarIcon: ({ color }) => <IconStatus color={color} />,
}}
/>
<Tabs.Screen
name="contribution"
options={{
title: "Kontribusi",
tabBarIcon: ({ color }) => <IconContribution color={color} />,
}}
/>
<Tabs.Screen
name="history"
options={{
title: "Riwayat",
tabBarIcon: ({ color }) => <IconHistory color={color} />,
}}
/>
</Tabs>
</View>
);
}
export default function EventTabsLayout() {
return <EventTabsWrapper />;
}

View File

@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable react-hooks/exhaustive-deps */
import { BasicWrapper, StackCustom, ViewWrapper } from "@/components";
import { BasicWrapper, Spacing, StackCustom, ViewWrapper } from "@/components";
import AppHeader from "@/components/_ShareComponent/AppHeader";
import CustomSkeleton from "@/components/_ShareComponent/SkeletonCustom";
import { MainColor } from "@/constants/color-palet";
@@ -8,19 +8,20 @@ import { useAuth } from "@/hooks/use-auth";
import { useNotificationStore } from "@/hooks/use-notification-store";
import Home_BottomFeatureSection from "@/screens/Home/bottomFeatureSection";
import HeaderBell from "@/screens/Home/HeaderBell";
import HomeTabs from "@/screens/Home/HomeTabs";
import { stylesHome } from "@/screens/Home/homeViewStyle";
import Home_ImageSection from "@/screens/Home/imageSection";
import TabSection from "@/screens/Home/tabSection";
import { tabsHome } from "@/screens/Home/tabsList";
import Home_FeatureSection from "@/screens/Home/topFeatureSection";
import { apiJobGetAll } from "@/service/api-client/api-job";
import { apiUser } from "@/service/api-client/api-user";
import { apiVersion } from "@/service/api-config";
import { GStyles } from "@/styles/global-styles";
import { Ionicons } from "@expo/vector-icons";
import { Redirect, router, Stack, useFocusEffect } from "expo-router";
import { useCallback, useState } from "react";
import { RefreshControl, View } from "react-native";
import { RefreshControl, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Platform } from "react-native";
export default function Application() {
const { token, user, userData } = useAuth();
@@ -28,6 +29,8 @@ export default function Application() {
const [refreshing, setRefreshing] = useState(false);
const { syncUnreadCount } = useNotificationStore();
const [listData, setListData] = useState<any[] | null>(null);
const insets = useSafeAreaInsets();
const paddingBottom = Platform.OS === "android" ? insets.bottom : 0;
useFocusEffect(
useCallback(() => {
@@ -105,15 +108,6 @@ export default function Application() {
);
}
// if (data && data?.masterUserRoleId !== "1") {
// console.log("User is not admin");
// return (
// <BasicWrapper>
// <Redirect href={`/admin/dashboard`} />
// </BasicWrapper>
// );
// }
return (
<>
<Stack.Screen
@@ -148,63 +142,61 @@ export default function Application() {
}}
/>
<ViewWrapper
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={MainColor.yellow}
colors={[MainColor.yellow]}
/>
}
footerComponent={
data && data ? (
<TabSection
tabs={tabsHome({
acceptedForumTermsAt: data?.acceptedForumTermsAt,
profileId: data?.Profile?.id,
})}
<View style={{ flex: 1, backgroundColor: MainColor.darkblue }}>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
flexGrow: 1,
paddingInline: 10,
paddingBottom: paddingBottom + 80, // Space for tabs + safe area
}}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={MainColor.yellow}
colors={[MainColor.yellow]}
/>
) : (
<View style={GStyles.tabBar}>
<View style={[GStyles.tabContainer, { paddingTop: 10 }]}>
{Array.from({ length: 4 }).map((e, index) => (
}
keyboardShouldPersistTaps="handled"
>
<StackCustom>
<Home_ImageSection />
{data && data ? (
<Home_FeatureSection />
) : (
<View style={stylesHome.gridContainer}>
{Array.from({ length: 4 }).map((_, index) => (
<CustomSkeleton
key={index}
height={40}
width={40}
radius={100}
style={stylesHome.gridItem}
radius={50}
/>
))}
</View>
</View>
)
}
>
<StackCustom>
<Home_ImageSection />
)}
{data && data ? (
<Home_FeatureSection />
) : (
<View style={stylesHome.gridContainer}>
{Array.from({ length: 4 }).map((item, index) => (
<CustomSkeleton
key={index}
style={stylesHome.gridItem}
radius={50}
/>
))}
</View>
)}
{data ? (
<Home_BottomFeatureSection listData={listData} />
) : (
<CustomSkeleton height={150} />
)}
</StackCustom>
</ScrollView>
{data ? (
<Home_BottomFeatureSection listData={listData} />
) : (
<CustomSkeleton height={200} />
)}
</StackCustom>
</ViewWrapper>
{/* Home Tabs di bawah */}
{data && data ? (
<HomeTabs
tabs={tabsHome({
acceptedForumTermsAt: data?.acceptedForumTermsAt,
profileId: data?.Profile?.id,
})}
/>
) : (
<View style={{ height: 80 + paddingBottom, backgroundColor: MainColor.darkblue }} />
)}
</View>
</>
);
}

View File

@@ -4,80 +4,105 @@ import { TabsStyles } from "@/styles/tabs-styles";
import { Feather, FontAwesome6, Ionicons } from "@expo/vector-icons";
import { router, Tabs, useLocalSearchParams, useNavigation } from "expo-router";
import { useLayoutEffect } from "react";
import { View } from "react-native";
import { MainColor } from "@/constants/color-palet";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Platform } from "react-native";
export default function InvestmentTabsLayout() {
// const navigation = useNavigation();
function InvestmentTabsWrapper() {
const insets = useSafeAreaInsets();
const paddingBottom = Platform.OS === "android" ? insets.bottom : 0;
const navigation = useNavigation();
// const { from, category } = useLocalSearchParams<{
// from?: string;
// category?: string;
// }>();
const { from, category } = useLocalSearchParams<{
from?: string;
category?: string;
}>();
// console.log("from", from);
// console.log("category", category);
// // Atur header secara dinamis
// useLayoutEffect(() => {
// navigation.setOptions({
// headerLeft: () => (
// <BackButtonFromNotification
// from={from as string}
// category={category as string}
// />
// ),
// });
// }, [from, router, navigation]);
// Atur header secara dinamis
useLayoutEffect(() => {
navigation.setOptions({
headerLeft: () => (
<BackButtonFromNotification
from={from || ""}
category={category}
/>
),
});
}, [from, category, router, navigation]);
return (
<Tabs screenOptions={TabsStyles}>
<Tabs.Screen
name="index"
options={{
title: "Bursa",
tabBarIcon: ({ color }) => (
<Ionicons
name="bar-chart-outline"
color={color}
size={ICON_SIZE_SMALL}
/>
),
<View style={{ flex: 1, backgroundColor: MainColor.darkblue }}>
<Tabs
screenOptions={{
...TabsStyles,
tabBarStyle: Platform.select({
ios: {
borderTopWidth: 0,
paddingTop: 12,
height: 80,
},
android: {
borderTopWidth: 0,
paddingTop: 5,
height: 70 + paddingBottom,
},
}),
}}
/>
<Tabs.Screen
name="portofolio"
options={{
title: "Portofolio",
tabBarIcon: ({ color }) => (
<Feather name="pie-chart" color={color} size={ICON_SIZE_SMALL} />
),
}}
/>
<Tabs.Screen
name="my-holding"
options={{
title: "Saham Saya",
tabBarIcon: ({ color }) => (
<FontAwesome6
name="hand-holding-dollar"
color={color}
size={ICON_SIZE_SMALL}
/>
),
}}
/>
<Tabs.Screen
name="transaction"
options={{
title: "Transaksi",
tabBarIcon: ({ color }) => (
<FontAwesome6
name="money-bill-transfer"
color={color}
size={ICON_SIZE_SMALL}
/>
),
}}
/>
</Tabs>
>
<Tabs.Screen
name="index"
options={{
title: "Bursa",
tabBarIcon: ({ color }) => (
<Ionicons
name="bar-chart-outline"
color={color}
size={ICON_SIZE_SMALL}
/>
),
}}
/>
<Tabs.Screen
name="portofolio"
options={{
title: "Portofolio",
tabBarIcon: ({ color }) => (
<Feather name="pie-chart" color={color} size={ICON_SIZE_SMALL} />
),
}}
/>
<Tabs.Screen
name="my-holding"
options={{
title: "Saham Saya",
tabBarIcon: ({ color }) => (
<FontAwesome6
name="hand-holding-dollar"
color={color}
size={ICON_SIZE_SMALL}
/>
),
}}
/>
<Tabs.Screen
name="transaction"
options={{
title: "Transaksi",
tabBarIcon: ({ color }) => (
<FontAwesome6
name="money-bill-transfer"
color={color}
size={ICON_SIZE_SMALL}
/>
),
}}
/>
</Tabs>
</View>
);
}
export default function InvestmentTabsLayout() {
return <InvestmentTabsWrapper />;
}

View File

@@ -10,23 +10,41 @@ import {
Tabs,
useLocalSearchParams
} from "expo-router";
import { View } from "react-native";
import { MainColor } from "@/constants/color-palet";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Platform } from "react-native";
export default function JobTabsLayout() {
function JobTabsWrapper() {
const insets = useSafeAreaInsets();
const paddingBottom = Platform.OS === "android" ? insets.bottom : 0;
const { from, category } = useLocalSearchParams<{
from?: string;
category?: string;
}>();
return (
<>
<View style={{ flex: 1, backgroundColor: MainColor.darkblue }}>
<Tabs
screenOptions={{
...TabsStyles,
tabBarStyle: Platform.select({
ios: {
borderTopWidth: 0,
paddingTop: 12,
height: 80,
},
android: {
borderTopWidth: 0,
paddingTop: 5,
height: 70 + paddingBottom,
},
}),
header: () => (
<AppHeader
title="Job Vacancy"
left={
<BackButtonFromNotification from={from as string} category={category as string} />
<BackButtonFromNotification from={from || ""} category={category} />
}
/>
),
@@ -56,6 +74,10 @@ export default function JobTabsLayout() {
}}
/>
</Tabs>
</>
</View>
);
}
export default function JobTabsLayout() {
return <JobTabsWrapper />;
}

View File

@@ -5,9 +5,9 @@ import {
DrawerCustom,
LoaderCustom,
MenuDrawerDynamicGrid,
NewWrapper_V2,
Spacing,
StackCustom,
ViewWrapper,
} from "@/components";
import AppHeader from "@/components/_ShareComponent/AppHeader";
import { IconEdit } from "@/components/_Icon";
@@ -72,7 +72,7 @@ export default function JobDetailStatus() {
),
}}
/>
<ViewWrapper>
<NewWrapper_V2>
{isLoadData ? (
<LoaderCustom />
) : (
@@ -83,7 +83,7 @@ export default function JobDetailStatus() {
(status === "draft" || status === "reject") && (
<ReportBox text={data?.catatan} />
)}
<Job_BoxDetailSection data={data} />
<Job_ButtonStatusSection
id={id as string}
@@ -96,7 +96,7 @@ export default function JobDetailStatus() {
<Spacing />
</>
)}
</ViewWrapper>
</NewWrapper_V2>
<DrawerCustom
isVisible={openDrawer}

View File

@@ -1,198 +1,5 @@
/* 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";
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>
);
return <Job_ScreenEdit />;
}

View File

@@ -1,168 +1,5 @@
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";
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>
);
return <Job_ScreenCreate />;
}

View File

@@ -1,5 +1,5 @@
import { Admin_ScreenPortofolioCreate } from "@/screens/Portofolio/ScreenPortofolioCreate";
import { ScreenPortofolioCreate } from "@/screens/Portofolio/ScreenPortofolioCreate";
export default function PortofolioCreate() {
return <Admin_ScreenPortofolioCreate />;
return <ScreenPortofolioCreate />;
}

View File

@@ -5,6 +5,7 @@ import {
ButtonCustom,
CenterCustom,
NewWrapper,
PhoneInputCustom,
SelectCustom,
Spacing,
StackCustom,
@@ -15,6 +16,7 @@ import {
import ListSkeletonComponent from "@/components/_ShareComponent/ListSkeletonComponent";
import { MainColor } from "@/constants/color-palet";
import { ICON_SIZE_XLARGE } from "@/constants/constans-value";
import { DEFAULT_COUNTRY, type CountryData, COUNTRIES } from "@/constants/countries";
import {
apiMasterBidangBisnis,
apiMasterSubBidangBisnis,
@@ -32,7 +34,6 @@ import { router, useLocalSearchParams } from "expo-router";
import _ from "lodash";
import { useEffect, useState } from "react";
import { Text, View } from "react-native";
import PhoneInput, { ICountry } from "react-native-international-phone-number";
import { ActivityIndicator } from "react-native-paper";
import Toast from "react-native-toast-message";
@@ -59,8 +60,8 @@ export default function PortofolioEdit() {
const { id } = useLocalSearchParams();
const [isLoading, setIsLoading] = useState(false);
const [data, setData] = useState<any>({});
const [selectedCountry, setSelectedCountry] = useState<null | ICountry>(null);
const [phoneNumber, setPhoneNumber] = useState<string>("");
const [selectedCountry, setSelectedCountry] = useState<CountryData>(DEFAULT_COUNTRY);
const [bidangBisnis, setBidangBisnis] = useState<
IMasterBidangBisnis[] | null
>(null);
@@ -72,12 +73,42 @@ export default function PortofolioEdit() {
IListSubBidangSelected[]
>([]);
function handleInputValue(phoneNumber: string) {
setData({ ...data, tlpn: phoneNumber });
function handlePhoneChange(phone: string) {
setPhoneNumber(phone);
// Format phone number for API
const callingCode = selectedCountry.callingCode;
let fixNumber = phone.replace(/\s+/g, "").replace(/^0+/, "");
// Remove country code if already present
if (fixNumber.startsWith(callingCode)) {
fixNumber = fixNumber.substring(callingCode.length);
}
// Remove leading zero
fixNumber = fixNumber.replace(/^0+/, "");
const realNumber = callingCode + fixNumber;
setData({ ...data, tlpn: realNumber });
}
function handleSelectedCountry(country: ICountry) {
function handleCountryChange(country: CountryData) {
setSelectedCountry(country);
// Re-format with new country code
const callingCode = country.callingCode;
let fixNumber = phoneNumber.replace(/\s+/g, "").replace(/^0+/, "");
// Remove country code if already present
if (fixNumber.startsWith(callingCode)) {
fixNumber = fixNumber.substring(callingCode.length);
}
// Remove leading zero
fixNumber = fixNumber.replace(/^0+/, "");
const realNumber = callingCode + fixNumber;
setData({ ...data, tlpn: realNumber });
}
const onLoadMasterBidang = async () => {
@@ -122,8 +153,27 @@ export default function PortofolioEdit() {
const response = await apiGetOnePortofolio({ id: id });
if (response.success) {
const fixNumber = response.data.tlpn.replace("62", "");
setData({ ...response.data, tlpn: fixNumber });
// Extract phone number without country code for display
const fullNumber = response.data.tlpn;
let displayNumber = fullNumber;
let detectedCountry = DEFAULT_COUNTRY;
// Try to detect country from calling code
for (const country of COUNTRIES) {
if (fullNumber.startsWith(country.callingCode)) {
detectedCountry = country;
displayNumber = fullNumber.substring(country.callingCode.length);
break;
}
}
setSelectedCountry(detectedCountry);
// Remove leading zero if present
displayNumber = displayNumber.replace(/^0+/, "");
setPhoneNumber(displayNumber);
setData({ ...response.data, tlpn: displayNumber });
// Cek apakah ada sub bidang bisnis yang terpilih
const prevSubBidang = response.data.Portofolio_BidangDanSubBidangBisnis;
@@ -244,15 +294,11 @@ export default function PortofolioEdit() {
}
const handleSubmitUpdate = async () => {
const callingCode = selectedCountry?.callingCode.replace(/^\+/, "") || "";
let fixNumber = data.tlpn.replace(/\s+/g, "").replace(/^0+/, "");
const realNumber = callingCode + fixNumber;
const newData: IFormData = {
id_Portofolio: data.id_Portofolio,
namaBisnis: data.namaBisnis,
alamatKantor: data.alamatKantor,
tlpn: realNumber,
tlpn: data.tlpn, // Already formatted by PhoneInputCustom
deskripsi: data.deskripsi,
masterBidangBisnisId: data.masterBidangBisnisId,
subBidang: listSubBidangSelected,
@@ -435,12 +481,11 @@ export default function PortofolioEdit() {
<Text style={{ color: "red" }}> *</Text>
</View>
<Spacing height={5} />
<PhoneInput
value={data.tlpn}
onChangePhoneNumber={handleInputValue}
<PhoneInputCustom
value={phoneNumber}
onChangePhoneNumber={handlePhoneChange}
selectedCountry={selectedCountry}
onChangeSelectedCountry={handleSelectedCountry}
defaultCountry="ID"
onChangeCountry={handleCountryChange}
placeholder="xxx-xxx-xxx"
/>
</View>

View File

@@ -8,58 +8,82 @@ import AppHeader from "@/components/_ShareComponent/AppHeader";
import BackButtonFromNotification from "@/components/Button/BackButtonFromNotification";
import { TabsStyles } from "@/styles/tabs-styles";
import { router, Tabs, useLocalSearchParams } from "expo-router";
import { View } from "react-native";
import { MainColor } from "@/constants/color-palet";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Platform } from "react-native";
export default function VotingTabsLayout() {
function VotingTabsWrapper() {
const insets = useSafeAreaInsets();
const paddingBottom = Platform.OS === "android" ? insets.bottom : 0;
const { from, category } = useLocalSearchParams<{
from?: string;
category?: string;
}>();
return (
<Tabs
screenOptions={{
...TabsStyles,
header: () => (
<AppHeader
title="Voting"
left={
<BackButtonFromNotification
from={from as string}
category={category as string}
/>
}
/>
),
}}
>
<Tabs.Screen
name="index"
options={{
title: "Beranda",
tabBarIcon: ({ color }) => <IconHome color={color} />,
<View style={{ flex: 1, backgroundColor: MainColor.darkblue }}>
<Tabs
screenOptions={{
...TabsStyles,
tabBarStyle: Platform.select({
ios: {
borderTopWidth: 0,
paddingTop: 12,
height: 80,
},
android: {
borderTopWidth: 0,
paddingTop: 5,
height: 70 + paddingBottom,
},
}),
header: () => (
<AppHeader
title="Voting"
left={
<BackButtonFromNotification
from={from || ""}
category={category}
/>
}
/>
),
}}
/>
<Tabs.Screen
name="status"
options={{
title: "Status",
tabBarIcon: ({ color }) => <IconStatus color={color} />,
}}
/>
<Tabs.Screen
name="contribution"
options={{
title: "Kontribusi",
tabBarIcon: ({ color }) => <IconContribution color={color} />,
}}
/>
<Tabs.Screen
name="history"
options={{
title: "Riwayat",
tabBarIcon: ({ color }) => <IconHistory color={color} />,
}}
/>
</Tabs>
>
<Tabs.Screen
name="index"
options={{
title: "Beranda",
tabBarIcon: ({ color }) => <IconHome color={color} />,
}}
/>
<Tabs.Screen
name="status"
options={{
title: "Status",
tabBarIcon: ({ color }) => <IconStatus color={color} />,
}}
/>
<Tabs.Screen
name="contribution"
options={{
title: "Kontribusi",
tabBarIcon: ({ color }) => <IconContribution color={color} />,
}}
/>
<Tabs.Screen
name="history"
options={{
title: "Riwayat",
tabBarIcon: ({ color }) => <IconHistory color={color} />,
}}
/>
</Tabs>
</View>
);
}
export default function VotingTabsLayout() {
return <VotingTabsWrapper />;
}

View File

@@ -41,6 +41,7 @@
"expo-symbols": "~1.0.7",
"expo-system-ui": "~6.0.7",
"expo-web-browser": "~15.0.9",
"libphonenumber-js": "^1.12.40",
"lodash": "^4.17.21",
"moti": "^0.30.0",
"react": "19.1.0",
@@ -1772,6 +1773,8 @@
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"libphonenumber-js": ["libphonenumber-js@1.12.40", "", {}, "sha512-HKGs7GowShNls3Zh+7DTr6wYpPk5jC78l508yQQY3e8ZgJChM3A9JZghmMJZuK+5bogSfuTafpjksGSR3aMIEg=="],
"lighthouse-logger": ["lighthouse-logger@1.4.2", "", { "dependencies": { "debug": "^2.6.9", "marky": "^1.2.2" } }, "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g=="],
"lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],

View File

@@ -0,0 +1,256 @@
import { MainColor } from "@/constants/color-palet";
import {
DEFAULT_COUNTRY,
searchCountries,
type CountryData,
} from "@/constants/countries";
import { useState } from "react";
import {
Modal,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
interface PhoneInputProps {
value: string;
onChangePhoneNumber: (phone: string) => void;
selectedCountry?: CountryData;
onChangeCountry: (country: CountryData) => void;
placeholder?: string;
disabled?: boolean;
}
export default function PhoneInputCustom({
value,
onChangePhoneNumber,
selectedCountry = DEFAULT_COUNTRY,
onChangeCountry,
placeholder = "Masukkan nomor",
disabled = false,
}: PhoneInputProps) {
const [countryPickerVisible, setCountryPickerVisible] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const filteredCountries = searchCountries(searchQuery);
const handleSelectCountry = (country: CountryData) => {
onChangeCountry(country);
setCountryPickerVisible(false);
setSearchQuery("");
};
const handlePhoneChange = (text: string) => {
// Only allow numbers and spaces
const cleaned = text.replace(/[^\d\s]/g, "");
onChangePhoneNumber(cleaned);
};
return (
<>
{/* Phone Input Field */}
<View style={styles.container}>
<TouchableOpacity
style={styles.countryPickerButton}
onPress={() => setCountryPickerVisible(true)}
disabled={disabled}
activeOpacity={0.7}
>
<Text style={styles.countryCodeText}>+{selectedCountry.callingCode}</Text>
</TouchableOpacity>
<View style={styles.divider} />
<TextInput
style={[styles.phoneInput, disabled && styles.disabledInput]}
placeholder={placeholder}
placeholderTextColor={MainColor.placeholder}
value={value}
onChangeText={handlePhoneChange}
keyboardType="phone-pad"
autoComplete="tel"
importantForAutofill="yes"
editable={!disabled}
/>
</View>
{/* Country Picker Modal */}
<Modal
visible={countryPickerVisible}
transparent
animationType="slide"
onRequestClose={() => setCountryPickerVisible(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Pilih Negara</Text>
<TouchableOpacity onPress={() => setCountryPickerVisible(false)}>
<Text style={styles.modalClose}></Text>
</TouchableOpacity>
</View>
<TextInput
style={styles.searchInput}
placeholder="Cari negara atau kode..."
placeholderTextColor={MainColor.placeholder}
value={searchQuery}
onChangeText={setSearchQuery}
autoFocus
/>
<ScrollView style={styles.countryList}>
{filteredCountries.map((country) => (
<TouchableOpacity
key={country.code}
style={[
styles.countryItem,
selectedCountry.code === country.code &&
styles.countryItemSelected,
]}
onPress={() => handleSelectCountry(country)}
activeOpacity={0.7}
>
<View style={styles.countryInfo}>
<Text style={styles.countryName}>{country.name}</Text>
<Text style={styles.countryCode}>+{country.callingCode}</Text>
</View>
{selectedCountry.code === country.code && (
<Text style={styles.checkmark}></Text>
)}
</TouchableOpacity>
))}
</ScrollView>
</View>
</View>
</Modal>
</>
);
}
const styles = StyleSheet.create({
// Container
container: {
flexDirection: "row",
backgroundColor: MainColor.white,
borderRadius: 8,
borderWidth: 1,
borderColor: MainColor.white_gray,
marginBottom: 16,
overflow: "hidden",
},
// Country Picker Button
countryPickerButton: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 16,
paddingVertical: 14,
backgroundColor: MainColor.text_input,
borderRightWidth: 1,
borderRightColor: MainColor.white_gray,
},
countryCodeText: {
fontSize: 16,
color: MainColor.black,
fontWeight: "600",
},
dropdownIcon: {
fontSize: 18,
color: MainColor.placeholder,
marginLeft: 4,
},
// Divider
divider: {
width: 1,
backgroundColor: MainColor.white_gray,
},
// Phone Input
phoneInput: {
flex: 1,
paddingVertical: 14,
paddingHorizontal: 12,
fontSize: 16,
color: MainColor.black,
},
disabledInput: {
backgroundColor: MainColor.text_input,
color: MainColor.placeholder,
},
// Modal
modalOverlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
},
modalContent: {
backgroundColor: MainColor.white,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
maxHeight: "80%",
paddingBottom: 34,
},
modalHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 20,
borderBottomWidth: 1,
borderBottomColor: MainColor.white_gray,
},
modalTitle: {
fontSize: 18,
fontWeight: "bold",
color: MainColor.black,
},
modalClose: {
fontSize: 24,
color: MainColor.placeholder,
padding: 5,
},
// Search Input
searchInput: {
backgroundColor: MainColor.text_input,
margin: 16,
padding: 12,
borderRadius: 8,
fontSize: 16,
color: MainColor.black,
},
// Country List
countryList: {
paddingHorizontal: 16,
},
countryItem: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: 12,
paddingHorizontal: 12,
borderBottomWidth: 1,
borderBottomColor: MainColor.white_gray,
},
countryItemSelected: {
backgroundColor: MainColor.soft_darkblue + "15",
},
countryInfo: {
flex: 1,
},
countryName: {
fontSize: 16,
color: MainColor.black,
fontWeight: "500",
},
countryCode: {
fontSize: 14,
color: MainColor.placeholder,
marginTop: 2,
},
checkmark: {
fontSize: 20,
color: MainColor.green,
fontWeight: "bold",
},
});

View File

@@ -30,7 +30,7 @@ export default function AppHeader({
? isIOS26Plus
? insets.top - 10
: insets.top
: 10;
: 40;
const paddingBottom = Platform.OS === "ios" ? 8 : 13;

View 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>
);
}

View File

@@ -19,6 +19,7 @@ import {
SafeAreaView,
} from "react-native-safe-area-context";
import type { ScrollViewProps, FlatListProps } from "react-native";
import Spacing from "./Spacing";
// --- ✅ Tambahkan refreshControl ke BaseProps ---
interface BaseProps {
@@ -83,7 +84,7 @@ const NewWrapper = (props: NewWrapperProps) => {
return <View style={[GStyles.container, style]}>{content}</View>;
};
// 🔹 Mode Dinamis
// 🔹 Mode Dinamis (FlatList)
if ("listData" in props) {
const listProps = props as ListModeProps;
@@ -95,7 +96,7 @@ const NewWrapper = (props: NewWrapperProps) => {
{headerComponent && (
<View style={GStyles.stickyHeader}>{headerComponent}</View>
)}
<View style={[GStyles.container, style]}>
<View style={[GStyles.container, style, { flex: 1 }]}>
<FlatList
data={listProps.listData}
renderItem={listProps.renderItem}
@@ -107,30 +108,36 @@ const NewWrapper = (props: NewWrapperProps) => {
return `fallback-${index}-${JSON.stringify(item)}`;
}
// Gabungkan ID dengan indeks untuk mencegah duplikasi
return `${String(item.id)}-${index}`;
})
}
refreshControl={refreshControl} // ✅ dari BaseProps
refreshControl={refreshControl}
onEndReached={listProps.onEndReached}
onEndReachedThreshold={0.5}
ListHeaderComponent={listProps.ListHeaderComponent}
ListFooterComponent={listProps.ListFooterComponent}
ListEmptyComponent={listProps.ListEmptyComponent}
contentContainerStyle={{ flexGrow: 1 }}
contentContainerStyle={{
flexGrow: 1,
paddingBottom: footerComponent && !hideFooter ? OS_HEIGHT : 0
}}
keyboardShouldPersistTaps="handled"
/>
</View>
{footerComponent ? (
<SafeAreaView
edges={Platform.OS === "ios" ? edgesFooter : ["bottom"]}
style={{ backgroundColor: MainColor.darkblue, height: OS_HEIGHT }}
>
{footerComponent}
</SafeAreaView>
) : hideFooter ? null : (
{/* Footer - tetap di bawah dengan position absolute */}
{footerComponent && !hideFooter && (
<View style={styles.footerContainer}>
<SafeAreaView
edges={Platform.OS === "ios" ? edgesFooter : ["bottom"]}
style={{ backgroundColor: MainColor.darkblue }}
>
{footerComponent}
</SafeAreaView>
</View>
)}
{!footerComponent && !hideFooter && (
<SafeAreaView
edges={["bottom"]}
style={{ backgroundColor: MainColor.darkblue }}
@@ -144,7 +151,7 @@ const NewWrapper = (props: NewWrapperProps) => {
);
}
// 🔹 Mode Statis
// 🔹 Mode Statis (ScrollView)
const staticProps = props as StaticModeProps;
return (
@@ -156,24 +163,34 @@ const NewWrapper = (props: NewWrapperProps) => {
<View style={GStyles.stickyHeader}>{headerComponent}</View>
)}
<ScrollView
contentContainerStyle={{ flexGrow: 1 }}
keyboardShouldPersistTaps="handled"
refreshControl={refreshControl} // ✅ sekarang valid
>
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
{renderContainer(staticProps.children)}
</TouchableWithoutFeedback>
</ScrollView>
{footerComponent ? (
<SafeAreaView
edges={Platform.OS === "ios" ? edgesFooter : ["bottom"]}
style={{ backgroundColor: MainColor.darkblue, height: OS_HEIGHT }}
<View style={{ flex: 1 }}>
<ScrollView
contentContainerStyle={{
flexGrow: 1,
paddingBottom: footerComponent && !hideFooter ? OS_HEIGHT : 0
}}
keyboardShouldPersistTaps="handled"
refreshControl={refreshControl}
>
{footerComponent}
</SafeAreaView>
) : hideFooter ? null : (
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
{renderContainer(staticProps.children)}
</TouchableWithoutFeedback>
</ScrollView>
</View>
{/* Footer - tetap di bawah dengan position absolute */}
{footerComponent && !hideFooter && (
<View style={styles.footerContainer}>
<SafeAreaView
edges={Platform.OS === "ios" ? edgesFooter : ["bottom"]}
style={{ backgroundColor: MainColor.darkblue }}
>
{footerComponent}
</SafeAreaView>
</View>
)}
{!footerComponent && !hideFooter && (
<SafeAreaView
edges={["bottom"]}
style={{ backgroundColor: MainColor.darkblue }}
@@ -187,4 +204,15 @@ const NewWrapper = (props: NewWrapperProps) => {
);
};
// Styles untuk footer dengan position absolute
const styles = {
footerContainer: {
position: "absolute" as const,
bottom: 0,
left: 0,
right: 0,
backgroundColor: MainColor.darkblue,
},
};
export default NewWrapper;

View File

@@ -0,0 +1,231 @@
// 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;
/**
* Padding untuk content container (default: 16)
* Set to 0 untuk tidak ada padding, atau custom value sesuai kebutuhan
*/
contentPadding?: 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
contentPadding = 16, // Default 16 untuk padding konsisten
} = 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,
padding: contentPadding,
}}
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,
padding: contentPadding,
}}
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>
);
}

View File

@@ -49,6 +49,8 @@ import MapCustom from "./Map/MapCustom";
import CenterCustom from "./Center/CenterCustom";
// Clickable
import ClickableCustom from "./Clickable/ClickableCustom";
// PhoneInput
import PhoneInputCustom from "./PhoneInput/PhoneInputCustom";
// Scroll
import ScrollableCustom from "./Scroll/ScrollCustom";
// ShareComponent
@@ -61,6 +63,9 @@ import DummyLandscapeImage from "./_ShareComponent/DummyLandscapeImage";
import GridComponentView from "./_ShareComponent/GridSectionView";
import NewWrapper from "./_ShareComponent/NewWrapper";
import BasicWrapper from "./_ShareComponent/BasicWrapper";
import { FormWrapper } from "./_ShareComponent/FormWrapper";
import { NewWrapper_V2 } from "./_ShareComponent/NewWrapper_V2";
// Progress
import ProgressCustom from "./Progress/ProgressCustom";
// Loader
@@ -95,6 +100,8 @@ export {
CheckboxGroup,
// Clickable
ClickableCustom,
// PhoneInput
PhoneInputCustom,
// Container
CircleContainer,
// Divider
@@ -123,6 +130,8 @@ export {
Spacing,
NewWrapper,
BasicWrapper,
FormWrapper,
NewWrapper_V2,
// Stack
StackCustom,
TabBarBackground,

View File

@@ -23,8 +23,8 @@ export {
};
// OS Height
const OS_ANDROID_HEIGHT = 115
const OS_IOS_HEIGHT = 90
const OS_ANDROID_HEIGHT = 65
const OS_IOS_HEIGHT = 80
const OS_HEIGHT = Platform.OS === "ios" ? OS_IOS_HEIGHT : OS_ANDROID_HEIGHT
// Text Size

89
constants/countries.ts Normal file
View File

@@ -0,0 +1,89 @@
import { type CountryCode } from "libphonenumber-js";
/**
* Country data for phone number input
* Contains only country name and calling code (NO flags for maximum compatibility)
*/
export interface CountryData {
code: CountryCode;
name: string;
callingCode: string;
}
/**
* List of supported countries for phone number input
*
* @description
* This list includes major countries across different regions.
* Countries are ordered by likelihood of use (Indonesia first as default).
*
* @note
* NO emoji flags used - only text-based country name and calling code
* This ensures maximum compatibility across all platforms and iOS versions
*/
export const COUNTRIES: CountryData[] = [
// Asia Pacific (Primary markets)
{ code: "ID", name: "Indonesia", callingCode: "62" },
{ code: "SG", name: "Singapore", callingCode: "65" },
{ code: "MY", name: "Malaysia", callingCode: "60" },
{ code: "AU", name: "Australia", callingCode: "61" },
// Asia (Other)
{ code: "CN", name: "China", callingCode: "86" },
{ code: "JP", name: "Japan", callingCode: "81" },
{ code: "KR", name: "South Korea", callingCode: "82" },
{ code: "IN", name: "India", callingCode: "91" },
// Middle East
{ code: "AE", name: "United Arab Emirates", callingCode: "971" },
{ code: "SA", name: "Saudi Arabia", callingCode: "966" },
// Europe
{ code: "GB", name: "United Kingdom", callingCode: "44" },
{ code: "DE", name: "Germany", callingCode: "49" },
{ code: "FR", name: "France", callingCode: "33" },
{ code: "NL", name: "Netherlands", callingCode: "31" },
// Americas
{ code: "US", name: "United States", callingCode: "1" },
];
/**
* Default country for phone number input
* Used when no country is selected (Indonesia by default)
*/
export const DEFAULT_COUNTRY: CountryData = COUNTRIES[0];
/**
* Get country by calling code
* @param callingCode - The calling code to search for (e.g., "62", "1")
* @returns The matching country data or undefined if not found
*/
export function getCountryByCallingCode(callingCode: string): CountryData | undefined {
return COUNTRIES.find((country) => country.callingCode === callingCode);
}
/**
* Get country by country code (ISO 3166-1 alpha-2)
* @param code - The country code to search for (e.g., "ID", "US")
* @returns The matching country data or undefined if not found
*/
export function getCountryByCode(code: CountryCode): CountryData | undefined {
return COUNTRIES.find((country) => country.code === code);
}
/**
* Search countries by name or calling code
* @param query - The search query (case-insensitive)
* @returns Array of matching countries
*/
export function searchCountries(query: string): CountryData[] {
const normalizedQuery = query.toLowerCase().trim();
return COUNTRIES.filter(
(country) =>
country.name.toLowerCase().includes(normalizedQuery) ||
country.code.toLowerCase().includes(normalizedQuery) ||
country.callingCode.includes(normalizedQuery)
);
}

148
docs/KEYBOARD-BUG-TEST.md Normal file
View 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

View 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

View File

@@ -120,4 +120,9 @@ Buatkan file baru pada "Folder tujuan" dengan nama "Nama file utama" dan ubah na
<!-- END Create Box -->
<!-- Random Prompt -->
Diskusi pada file screens/Authentication/LoginView.tsx , tentang penggunaan phone number input. Karena tidak berfungsi dengan baik pada versi ios 26 keatas
<!-- END Random Prompt -->
<!-- END Use Prompt Now -->

67
hooks/useKeyboardForm.ts Normal file
View 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,
};
}

File diff suppressed because one or more lines are too long

View File

@@ -1,15 +0,0 @@
{
"originHash" : "e70d3525c8e2819a8b34f22909815dab5c700c25a06c32388f3930f7b3627768",
"pins" : [
{
"identity" : "maplibre-gl-native-distribution",
"kind" : "remoteSourceControl",
"location" : "https://github.com/maplibre/maplibre-gl-native-distribution",
"state" : {
"revision" : "c68c970ff3ece56cfc3b36849db70167fa208beb",
"version" : "6.17.1"
}
}
],
"version" : 3
}

View File

@@ -6,7 +6,7 @@
<string>development</string>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:cld-dkr-hipmi-stg.wibudev.com</string>
<string>applinks:hipmi.muku.id</string>
</array>
</dict>
</plist>

View File

@@ -39,7 +39,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>5</string>
<string>7</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSMinimumSystemVersion</key>

View File

@@ -1,15 +1,22 @@
use_modular_headers!
require File.join(File.dirname(`node --print "require.resolve('expo/package.json')"`), "scripts/autolinking")
require File.join(File.dirname(`node --print "require.resolve('react-native/package.json')"`), "scripts/react_native_pods")
require 'json'
podfile_properties = JSON.parse(File.read(File.join(__dir__, 'Podfile.properties.json'))) rescue {}
def ccache_enabled?(podfile_properties)
# Environment variable takes precedence
return ENV['USE_CCACHE'] == '1' if ENV['USE_CCACHE']
# Fall back to Podfile properties
podfile_properties['apple.ccacheEnabled'] == 'true'
end
ENV['RCT_NEW_ARCH_ENABLED'] ||= '0' if podfile_properties['newArchEnabled'] == 'false'
ENV['EX_DEV_CLIENT_NETWORK_INSPECTOR'] ||= podfile_properties['EX_DEV_CLIENT_NETWORK_INSPECTOR']
ENV['RCT_USE_RN_DEP'] ||= '1' if podfile_properties['ios.buildReactNativeFromSource'] != 'true' && podfile_properties['newArchEnabled'] != 'false'
ENV['RCT_USE_PREBUILT_RNCORE'] ||= '1' if podfile_properties['ios.buildReactNativeFromSource'] != 'true' && podfile_properties['newArchEnabled'] != 'false'
use_modular_headers!
platform :ios, podfile_properties['ios.deploymentTarget'] || '15.1'
prepare_react_native_project!
@@ -21,7 +28,10 @@ target 'HIPMIBadungConnect' do
config_command = ['node', '-e', "process.argv=['', '', 'config'];require('@react-native-community/cli').run()"];
else
config_command = [
'npx',
'node',
'--no-warnings',
'--eval',
'require(\'expo/bin/autolinking\')',
'expo-modules-autolinking',
'react-native-config',
'--json',
@@ -35,7 +45,6 @@ target 'HIPMIBadungConnect' do
use_frameworks! :linkage => podfile_properties['ios.useFrameworks'].to_sym if podfile_properties['ios.useFrameworks']
use_frameworks! :linkage => ENV['USE_FRAMEWORKS'].to_sym if ENV['USE_FRAMEWORKS']
use_react_native!(
:path => config[:reactNativePath],
:hermes_enabled => podfile_properties['expo.jsEngine'] == nil || podfile_properties['expo.jsEngine'] == 'hermes',
@@ -44,23 +53,12 @@ target 'HIPMIBadungConnect' do
:privacy_file_aggregation_enabled => podfile_properties['apple.privacyManifestAggregationEnabled'] != 'false',
)
pod 'Firebase'
pod 'Firebase/Messaging'
# @generated begin post_installer - expo prebuild (DO NOT MODIFY) sync-4092f82b887b5b9edb84642c2a56984d69b9a403
post_install do |installer|
# @generated begin @maplibre/maplibre-react-native:post-install - expo prebuild (DO NOT MODIFY) sync-6e76c80af0d70c0003d06822dd59b7c729fca472
$MLRN.post_install(installer)
# @generated end @maplibre/maplibre-react-native:post-install
# Fix all script phases with incorrect paths
installer.pods_project.targets.each do |target|
target.build_phases.each do |phase|
next unless phase.respond_to?(:shell_script)
# Fix duplicated path issue
if phase.shell_script.include?('with-environment.sh')
# Remove any existing path and use proper relative path
phase.shell_script = phase.shell_script.gsub(
%r{(/.*?/node_modules/react-native)+/scripts/xcode/with-environment.sh},
'${PODS_ROOT}/../../node_modules/react-native/scripts/xcode/with-environment.sh'
@@ -68,15 +66,14 @@ target 'HIPMIBadungConnect' do
end
end
end
# Standard React Native post install
# @generated begin @maplibre/maplibre-react-native:post-install - expo prebuild (DO NOT MODIFY) sync-6e76c80af0d70c0003d06822dd59b7c729fca472
$MLRN.post_install(installer)
# @generated end @maplibre/maplibre-react-native:post-install
react_native_post_install(
installer,
config[:reactNativePath],
:mac_catalyst_enabled => false,
:ccache_enabled => podfile_properties['apple.ccacheEnabled'] == 'true',
:ccache_enabled => ccache_enabled?(podfile_properties),
)
end
# @generated end post_installer
end
end

View File

@@ -279,34 +279,11 @@ PODS:
- EXUpdatesInterface (2.0.0):
- ExpoModulesCore
- FBLazyVector (0.81.5)
- Firebase (12.8.0):
- Firebase/Core (= 12.8.0)
- Firebase/Core (12.8.0):
- Firebase/CoreOnly
- FirebaseAnalytics (~> 12.8.0)
- Firebase/CoreOnly (12.8.0):
- FirebaseCore (~> 12.8.0)
- Firebase/Messaging (12.8.0):
- Firebase/CoreOnly
- FirebaseMessaging (~> 12.8.0)
- FirebaseAnalytics (12.8.0):
- FirebaseAnalytics/Default (= 12.8.0)
- FirebaseCore (~> 12.8.0)
- FirebaseInstallations (~> 12.8.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- FirebaseAnalytics/Default (12.8.0):
- FirebaseCore (~> 12.8.0)
- FirebaseInstallations (~> 12.8.0)
- GoogleAppMeasurement/Default (= 12.8.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- FirebaseCore (12.8.0):
- FirebaseCoreInternal (~> 12.8.0)
- GoogleUtilities/Environment (~> 8.1)
@@ -329,33 +306,6 @@ PODS:
- GoogleUtilities/Reachability (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1)
- nanopb (~> 3.30910.0)
- GoogleAdsOnDeviceConversion (3.2.0):
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/Logger (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- nanopb (~> 3.30910.0)
- GoogleAppMeasurement/Core (12.8.0):
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- GoogleAppMeasurement/Default (12.8.0):
- GoogleAdsOnDeviceConversion (~> 3.2.0)
- GoogleAppMeasurement/Core (= 12.8.0)
- GoogleAppMeasurement/IdentitySupport (= 12.8.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- GoogleAppMeasurement/IdentitySupport (12.8.0):
- GoogleAppMeasurement/Core (= 12.8.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- GoogleDataTransport (10.1.0):
- nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4)
@@ -369,9 +319,6 @@ PODS:
- GoogleUtilities/Logger (8.1.0):
- GoogleUtilities/Environment
- GoogleUtilities/Privacy
- GoogleUtilities/MethodSwizzler (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GoogleUtilities/Network (8.1.0):
- GoogleUtilities/Logger
- "GoogleUtilities/NSData+zlib"
@@ -2581,9 +2528,9 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- SDWebImage (5.21.6):
- SDWebImage/Core (= 5.21.6)
- SDWebImage/Core (5.21.6)
- SDWebImage (5.21.7):
- SDWebImage/Core (= 5.21.7)
- SDWebImage/Core (5.21.7)
- SDWebImageAVIFCoder (0.11.1):
- libavif/core (>= 0.11.0)
- SDWebImage (~> 5.10)
@@ -2633,8 +2580,6 @@ DEPENDENCIES:
- ExpoWebBrowser (from `../node_modules/expo-web-browser/ios`)
- EXUpdatesInterface (from `../node_modules/expo-updates-interface/ios`)
- FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`)
- Firebase
- Firebase/Messaging
- hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)
- "maplibre-react-native (from `../node_modules/@maplibre/maplibre-react-native`)"
- RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)
@@ -2722,14 +2667,11 @@ DEPENDENCIES:
SPEC REPOS:
trunk:
- Firebase
- FirebaseAnalytics
- FirebaseCore
- FirebaseCoreExtension
- FirebaseCoreInternal
- FirebaseInstallations
- FirebaseMessaging
- GoogleAdsOnDeviceConversion
- GoogleAppMeasurement
- GoogleDataTransport
- GoogleUtilities
- libavif
@@ -3011,14 +2953,11 @@ SPEC CHECKSUMS:
EXUpdatesInterface: 5adf50cb41e079c861da6d9b4b954c3db9a50734
FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12
Firebase: 9a58fdbc9d8655ed7b79a19cf9690bb007d3d46d
FirebaseAnalytics: f20bbad8cb7f65d8a5eaefeb424ae8800a31bdfc
FirebaseCore: 0dbad74bda10b8fb9ca34ad8f375fb9dd3ebef7c
FirebaseCoreExtension: 6605938d51f765d8b18bfcafd2085276a252bee2
FirebaseCoreInternal: fe5fa466aeb314787093a7dce9f0beeaad5a2a21
FirebaseInstallations: 6a14ab3d694ebd9f839c48d330da5547e9ca9dc0
FirebaseMessaging: 7f42cfd10ec64181db4e01b305a613791c8e782c
GoogleAdsOnDeviceConversion: d68c69dd9581a0f5da02617b6f377e5be483970f
GoogleAppMeasurement: 72c9a682fec6290327ea5e3c4b829b247fcb2c17
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172
@@ -3107,13 +3046,13 @@ SPEC CHECKSUMS:
RNSVG: 31d6639663c249b7d5abc9728dde2041eb2a3c34
RNVectorIcons: 4351544f100d4f12cac156a7c13399e60bab3e26
RNWorklets: 43cd6af94c18f89cbca10ea83fee281b69d75da5
SDWebImage: 1bb6a1b84b6fe87b972a102bdc77dd589df33477
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c
SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380
Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a
ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5
PODFILE CHECKSUM: c099c57001b36661ca723fa0edfdb338496e8b9d
PODFILE CHECKSUM: 98fc0b2be4d9f9b5a23816e3c77ad0e74ea84fa0
COCOAPODS: 1.16.2

View File

@@ -48,6 +48,7 @@
"expo-symbols": "~1.0.7",
"expo-system-ui": "~6.0.7",
"expo-web-browser": "~15.0.9",
"libphonenumber-js": "^1.12.40",
"lodash": "^4.17.21",
"moti": "^0.30.0",
"react": "19.1.0",

287
plugins/withCustomConfig.js Normal file
View File

@@ -0,0 +1,287 @@
const {
withAppBuildGradle,
withProjectBuildGradle,
withInfoPlist,
} = require("@expo/config-plugins");
const { withPodfile } = require("@expo/config-plugins");
const { withAndroidManifest } = require("@expo/config-plugins");
const { withDangerousMod } = require("@expo/config-plugins");
const fs = require("fs");
const path = require("path");
// ─────────────────────────────────────────
// 1. PROJECT-LEVEL build.gradle
// Tambah: google-services classpath + Mapbox maven
// ─────────────────────────────────────────
const withCustomProjectBuildGradle = (config) => {
return withProjectBuildGradle(config, (config) => {
let contents = config.modResults.contents;
// Tambah google-services classpath jika belum ada
if (!contents.includes("com.google.gms:google-services")) {
contents = contents.replace(
/classpath\('com\.android\.tools\.build:gradle'\)/,
`classpath('com.android.tools.build:gradle')
classpath 'com.google.gms:google-services:4.4.1'`,
);
}
// Tambah Mapbox maven repository jika belum ada
if (!contents.includes("api.mapbox.com")) {
contents = contents.replace(
/allprojects\s*\{[\s\S]*?repositories\s*\{/,
`allprojects {
repositories {
maven {
url 'https://api.mapbox.com/downloads/v2/releases/maven'
def token = project.properties['MAPBOX_DOWNLOADS_TOKEN'] ?: System.getenv('RNMAPBOX_MAPS_DOWNLOAD_TOKEN')
if (token) {
authentication { basic(BasicAuthentication) }
credentials {
username = 'mapbox'
password = token
}
}
}`,
);
}
config.modResults.contents = contents;
return config;
});
};
// ─────────────────────────────────────────
// 2. APP-LEVEL build.gradle
// Tambah: buildConfigField + google-services plugin
// ─────────────────────────────────────────
const withCustomAppBuildGradle = (config) => {
return withAppBuildGradle(config, (config) => {
let contents = config.modResults.contents;
// Tambah Mapbox packagingOptions
if (!contents.includes("rnmapbox/maps-libcpp")) {
contents = contents.replace(
/android\s*\{/,
`android {
// @generated begin @rnmapbox/maps-libcpp - expo prebuild (DO NOT MODIFY) sync-e24830a5a3e854b398227dfe9630aabfaa1cadd1
packagingOptions {
pickFirst 'lib/x86/libc++_shared.so'
pickFirst 'lib/x86_64/libc++_shared.so'
pickFirst 'lib/arm64-v8a/libc++_shared.so'
pickFirst 'lib/armeabi-v7a/libc++_shared.so'
}
// @generated end @rnmapbox/maps-libcpp`,
);
}
// Tambah buildConfigField REACT_NATIVE_RELEASE_LEVEL
if (!contents.includes("REACT_NATIVE_RELEASE_LEVEL")) {
contents = contents.replace(
/defaultConfig\s*\{/,
`defaultConfig {
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\\"${`$`}{findProperty('reactNativeReleaseLevel') ?: 'stable'}\\""`,
);
}
// Tambah apply plugin google-services di akhir file
if (!contents.includes("com.google.gms.google-services")) {
contents += `\napply plugin: 'com.google.gms.google-services'\n`;
}
config.modResults.contents = contents;
return config;
});
};
// ─────────────────────────────────────────
// 3. Info.plist
// Tambah: custom URL schemes + deskripsi Bahasa Indonesia
// ─────────────────────────────────────────
const withCustomInfoPlist = (config) => {
return withInfoPlist(config, (config) => {
const plist = config.modResults;
// Custom URL Schemes
// Pastikan CFBundleURLTypes sudah ada, lalu tambahkan scheme custom
if (!plist.CFBundleURLTypes) {
plist.CFBundleURLTypes = [];
}
const hasHipmiScheme = plist.CFBundleURLTypes.some((entry) =>
entry.CFBundleURLSchemes?.includes("hipmimobile"),
);
if (!hasHipmiScheme) {
plist.CFBundleURLTypes.push({
CFBundleURLSchemes: ["hipmimobile", "com.anonymous.hipmi-mobile"],
});
}
// NSLocationWhenInUseUsageDescription — Bahasa Indonesia
plist.NSLocationWhenInUseUsageDescription =
"Aplikasi membutuhkan akses lokasi untuk menampilkan peta.";
// NSPhotoLibraryUsageDescription — Bahasa Indonesia (panjang)
plist.NSPhotoLibraryUsageDescription =
"Untuk mengunggah dokumen dan media bisnis seperti foto profil, logo usaha, poster lowongan, atau bukti transaksi di berbagai fitur aplikasi: Profile, Portofolio, Job Vacancy, Investasi, dan Donasi.";
plist.NSFaceIDUsageDescription =
"Allow $(PRODUCT_NAME) to access your Face ID biometric data.";
return config;
});
};
// ─────────────────────────────────────────
// 4. Android Manifest
// Tambah: backup rules untuk expo-secure-store
// ─────────────────────────────────────────
const withCustomManifest = (config) => {
return withAndroidManifest(config, (config) => {
const manifest = config.modResults.manifest;
const application = manifest.application[0];
// Tambah atribut backup untuk expo-secure-store
application.$["android:fullBackupContent"] =
"@xml/secure_store_backup_rules";
application.$["android:dataExtractionRules"] =
"@xml/secure_store_data_extraction_rules";
// Tambah tools:replace pada meta-data notification color
const metaDataList = application["meta-data"] || [];
const notifColorMeta = metaDataList.find(
(m) =>
m.$["android:name"] ===
"com.google.firebase.messaging.default_notification_color",
);
if (notifColorMeta) {
notifColorMeta.$["tools:replace"] = "android:resource";
}
return config;
});
};
// ─────────────────────────────────────────
// 5. Podfile
// Tambah: use_modular_headers!
// ─────────────────────────────────────────
const withCustomPodfile = (config) => {
return withPodfile(config, (config) => {
let contents = config.modResults.contents;
// Tambah use_modular_headers! jika belum ada
if (!contents.includes("use_modular_headers!")) {
contents = contents.replace(
/platform :ios/,
`use_modular_headers!\nplatform :ios`,
);
}
// Tambah Firebase pods jika belum ada
if (!contents.includes("pod 'Firebase/Messaging'")) {
contents = contents.replace(
/use_react_native_pods\!/,
`pod 'Firebase'\n pod 'Firebase/Messaging'\n\n use_react_native_pods!`,
);
}
// Tambah fix script with-environment.sh jika belum ada
// Tambah fix script with-environment.sh jika belum ada
if (!contents.includes("with-environment.sh")) {
const fixScript = [
"post_install do |installer|",
" # Fix all script phases with incorrect paths",
" installer.pods_project.targets.each do |target|",
" target.build_phases.each do |phase|",
" next unless phase.respond_to?(:shell_script)",
" if phase.shell_script.include?('with-environment.sh')",
" phase.shell_script = phase.shell_script.gsub(",
" %r{(/.*?/node_modules/react-native)+/scripts/xcode/with-environment.sh},",
" '${PODS_ROOT}/../../node_modules/react-native/scripts/xcode/with-environment.sh'",
" )",
" end",
" end",
" end",
].join("\n");
contents = contents.replace(/post_install do \|installer\|/, fixScript);
}
config.modResults.contents = contents;
return config;
});
};
// ─────────────────────────────────────────
// 6. Android XML Files
// Tambah: secure_store_backup_rules.xml dan secure_store_data_extraction_rules.xml
// ─────────────────────────────────────────
const withSecureStoreXml = (config) => {
return withDangerousMod(config, [
"android",
(config) => {
const xmlDir = path.join(
config.modRequest.platformProjectRoot,
"app/src/main/res/xml",
);
// Buat folder jika belum ada
if (!fs.existsSync(xmlDir)) {
fs.mkdirSync(xmlDir, { recursive: true });
}
// Definisikan path variabel di sini ← INI yang kurang sebelumnya
const backupRulesPath = path.join(
xmlDir,
"secure_store_backup_rules.xml",
);
const dataExtractionPath = path.join(
xmlDir,
"secure_store_data_extraction_rules.xml",
);
// secure_store_backup_rules.xml
fs.writeFileSync(
backupRulesPath,
`<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<exclude domain="sharedpref" path="SECURESTORE"/>
</full-backup-content>`,
);
// secure_store_data_extraction_rules.xml
fs.writeFileSync(
dataExtractionPath,
`<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<exclude domain="sharedpref" path="SECURESTORE"/>
</cloud-backup>
<device-transfer>
<exclude domain="sharedpref" path="SECURESTORE"/>
</device-transfer>
</data-extraction-rules>`,
);
return config;
},
]);
};
// ─────────────────────────────────────────
// EXPORT
// ─────────────────────────────────────────
module.exports = (config) => {
config = withCustomProjectBuildGradle(config);
config = withCustomAppBuildGradle(config);
config = withCustomManifest(config);
config = withSecureStoreXml(config);
config = withCustomInfoPlist(config);
config = withCustomPodfile(config);
return config;
};

View File

@@ -33,7 +33,10 @@ export function EventDetailQRCode({
const deepLinkURL = `${BASE_URL}/event/${id}/confirmation?userId=${userId}`;
// Toggle antara HTTPS link dan custom scheme
const qrValue = useHttpsLink ? httpsLink : deepLinkURL;
// const qrValue = useHttpsLink ? httpsLink : deepLinkURL;
const qrValue = deepLinkURL;
return (
<BaseBox>
@@ -46,7 +49,7 @@ export function EventDetailQRCode({
{qrValue}
</TextCustom>
<Spacing />
<StackCustom direction="row" gap="sm">
{/* <StackCustom direction="row" gap="sm">
<ButtonCustom
onPress={() => setUseHttpsLink(true)}
backgroundColor={useHttpsLink ? MainColor.yellow : "transparent"}
@@ -69,13 +72,13 @@ export function EventDetailQRCode({
>
Custom Scheme
</ButtonCustom>
</StackCustom>
<Spacing />
</StackCustom> */}
{/* <Spacing />
<TextCustom color="gray" align="center" size={"small"}>
{useHttpsLink
? "✅ Testing Universal Links/App Links (butuh .well-known config)"
: "🔧 Testing langsung (tanpa domain verification)"}
</TextCustom>
</TextCustom> */}
</BaseBox>
);
}

View File

@@ -128,12 +128,13 @@ export function Admin_ScreenEventDetail() {
);
}
return null;
return <Spacing height={100} />;
}, [status, id]);
return (
<>
<NewWrapper
hideFooter
headerComponent={headerComponent}
// footerComponent={
// <View style={{ paddingInline: 8 }}>

View File

@@ -1,8 +1,9 @@
import { NewWrapper } from "@/components";
import { NewWrapper, PhoneInputCustom, ViewWrapper } from "@/components";
import ButtonCustom from "@/components/Button/ButtonCustom";
import ModalReactNative from "@/components/Modal/ModalReactNative";
import Spacing from "@/components/_ShareComponent/Spacing";
import { MainColor } from "@/constants/color-palet";
import { DEFAULT_COUNTRY, type CountryData } from "@/constants/countries";
import { useAuth } from "@/hooks/use-auth";
import { apiVersion, BASE_URL } from "@/service/api-config";
import { GStyles } from "@/styles/global-styles";
@@ -10,16 +11,23 @@ import { openBrowser } from "@/utils/openBrower";
import versionBadge from "@/utils/viersionBadge";
import { Redirect } from "expo-router";
import { useEffect, useState } from "react";
import { RefreshControl, Text, View } from "react-native";
import PhoneInput, { ICountry } from "react-native-international-phone-number";
import {
KeyboardAvoidingView,
Platform,
RefreshControl,
Text,
View,
} from "react-native";
import { parsePhoneNumber } from "libphonenumber-js";
import Toast from "react-native-toast-message";
import EULASection from "./EULASection";
export default function LoginView() {
const url = BASE_URL;
const [version, setVersion] = useState<string>("");
const [selectedCountry, setSelectedCountry] = useState<null | ICountry>(null);
const [inputValue, setInputValue] = useState<string>("");
const [selectedCountry, setSelectedCountry] =
useState<CountryData>(DEFAULT_COUNTRY);
const [phoneNumber, setPhoneNumber] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const [refreshing, setRefreshing] = useState<boolean>(false);
const [modalVisible, setModalVisible] = useState(false);
@@ -43,41 +51,43 @@ export default function LoginView() {
async function handleRefresh() {
setRefreshing(true);
await onLoadVersion();
setInputValue("");
setPhoneNumber("");
setSelectedCountry(DEFAULT_COUNTRY);
setLoading(false);
setRefreshing(false);
}
function handleInputValue(phoneNumber: string) {
setInputValue(phoneNumber);
}
function handleSelectedCountry(country: ICountry) {
setSelectedCountry(country);
}
async function validateData() {
if (inputValue.length === 0) {
if (phoneNumber.length === 0) {
return Toast.show({
type: "error",
text1: "Masukan nomor anda",
});
}
if (selectedCountry === null) {
return Toast.show({
type: "error",
text1: "Pilih negara",
});
}
if (inputValue.length < 9) {
if (phoneNumber.length < 9) {
return Toast.show({
type: "error",
text1: "Nomor tidak valid",
});
}
// Validate with libphonenumber-js
try {
const parsedNumber = parsePhoneNumber(phoneNumber, selectedCountry.code);
if (!parsedNumber || !parsedNumber.isValid()) {
return Toast.show({
type: "error",
text1: "Nomor tidak valid",
});
}
} catch (error) {
return Toast.show({
type: "error",
text1: "Format nomor tidak valid",
});
}
return true;
}
@@ -85,8 +95,17 @@ export default function LoginView() {
const isValid = await validateData();
if (!isValid) return;
const callingCode = selectedCountry?.callingCode.replace(/^\+/, "") || "";
let fixNumber = inputValue.replace(/\s+/g, "").replace(/^0+/, "");
// Format phone number with country code
const callingCode = selectedCountry.callingCode;
let fixNumber = phoneNumber.replace(/\s+/g, "").replace(/^0+/, "");
// Remove country code if already present
if (fixNumber.startsWith(callingCode)) {
fixNumber = fixNumber.substring(callingCode.length);
}
// Remove leading zero
fixNumber = fixNumber.replace(/^0+/, "");
const realNumber = callingCode + fixNumber;
@@ -128,75 +147,84 @@ export default function LoginView() {
}
return (
<NewWrapper
withBackground
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
}
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
keyboardVerticalOffset={Platform.OS === "ios" ? 100 : 50}
style={{ flex: 1 }}
>
<View style={GStyles.authContainer}>
<View>
<View style={GStyles.authContainerTitle}>
<Text style={GStyles.authSubTitle}>WELCOME TO</Text>
<Spacing height={5} />
<Text style={GStyles.authTitle}>HIPMI BADUNG APPS</Text>
<Spacing height={5} />
<ViewWrapper
withBackground
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
}
>
<View style={[GStyles.authContainer, { paddingBottom: 40 }]}>
<View>
<View style={GStyles.authContainerTitle}>
<Text style={GStyles.authSubTitle}>WELCOME TO</Text>
<Spacing height={5} />
<Text style={GStyles.authTitle}>HIPMI BADUNG APPS</Text>
<Spacing height={5} />
</View>
<Spacing height={50} />
{version && (
<Text
style={{
position: "absolute",
bottom: 35,
right: 50,
fontSize: 10,
fontWeight: "thin",
fontStyle: "italic",
color: MainColor.white_gray,
}}
>
powered by muku.id
</Text>
)}
</View>
<Spacing height={50} />
<Text
style={{
position: "absolute",
bottom: 35,
right: 50,
fontSize: 10,
fontWeight: "thin",
fontStyle: "italic",
color: MainColor.white_gray,
}}
<Spacing height={20} />
<PhoneInputCustom
value={phoneNumber}
onChangePhoneNumber={setPhoneNumber}
selectedCountry={selectedCountry}
onChangeCountry={setSelectedCountry}
placeholder="Masukkan nomor"
/>
<Spacing />
<ButtonCustom
onPress={handleLogin}
disabled={loadingTerm}
isLoading={loading || loadingTerm}
>
{version} | powered by muku.id
Login
</ButtonCustom>
<Spacing height={50} />
<Text
style={{ ...GStyles.textLabel, textAlign: "center", fontSize: 12 }}
>
Dengan menggunakan aplikasi ini, Anda telah menyetujui{" "}
<Text
style={{
color: MainColor.yellow,
textDecorationLine: "underline",
}}
onPress={() => {
const toUrl = `${url}/terms-of-service.html`;
openBrowser(toUrl);
}}
>
Syarat & Ketentuan
</Text>{" "}
dan seluruh kebijakan privasi yang berlaku.
</Text>
</View>
<PhoneInput
value={inputValue}
onChangePhoneNumber={handleInputValue}
selectedCountry={selectedCountry}
onChangeSelectedCountry={handleSelectedCountry}
defaultCountry="ID"
placeholder="Masukkan nomor"
/>
<Spacing />
<ButtonCustom
onPress={handleLogin}
disabled={loadingTerm}
isLoading={loading || loadingTerm}
>
Login
</ButtonCustom>
<Spacing height={50} />
<Text
style={{ ...GStyles.textLabel, textAlign: "center", fontSize: 12 }}
>
Dengan menggunakan aplikasi ini, Anda telah menyetujui{" "}
<Text
style={{
color: MainColor.yellow,
textDecorationLine: "underline",
}}
onPress={() => {
const toUrl = `${url}/terms-of-service.html`;
openBrowser(toUrl);
}}
>
Syarat & Ketentuan
</Text>{" "}
dan seluruh kebijakan privasi yang berlaku.
</Text>
</View>
</ViewWrapper>
<ModalReactNative isVisible={modalVisible}>
<EULASection
@@ -205,6 +233,6 @@ export default function LoginView() {
setLoadingTerm={setLoadingTerm}
/>
</ModalReactNative>
</NewWrapper>
</KeyboardAvoidingView>
);
}

View File

@@ -18,7 +18,7 @@ import _ from "lodash";
import { useEffect, useState } from "react";
import { RefreshControl, TouchableOpacity, View } from "react-native";
const PAGE_SIZE = 5;
const PAGE_SIZE = 10;
export default function Forum_ViewBeranda3() {
const { user } = useAuth();

71
screens/Home/HomeTabs.tsx Normal file
View File

@@ -0,0 +1,71 @@
import { ICustomTab, ITabs } from "@/components/_Interface/types";
import { GStyles } from "@/styles/global-styles";
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import React from "react";
import { Platform, Text, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { MainColor } from "@/constants/color-palet";
interface HomeTabsProps {
tabs: ITabs[];
}
const CustomTab = ({ icon, label, isActive, onPress }: ICustomTab) => (
<TouchableOpacity
style={[GStyles.tabItem, isActive && GStyles.activeTab]}
onPress={onPress}
activeOpacity={0.7}
>
<View
style={[GStyles.iconContainer, isActive && GStyles.activeIconContainer]}
>
<Ionicons
name={icon as any}
size={18}
color={isActive ? "#fff" : "#666"}
/>
</View>
<Text style={[GStyles.tabLabel, isActive && GStyles.activeTabLabel]}>
{label}
</Text>
</TouchableOpacity>
);
/**
* Home Tabs Component dengan Safe Area handling
*
* Component ini menggunakan pattern yang sama dengan Expo Router Tabs
* untuk konsistensi safe area di Android
*/
export default function HomeTabs({ tabs }: HomeTabsProps) {
const insets = useSafeAreaInsets();
const paddingBottom = Platform.OS === "android" ? insets.bottom : 0;
return (
<View style={{ backgroundColor: MainColor.darkblue }}>
{/* Tabs content */}
<View style={GStyles.tabBar}>
<View style={GStyles.tabContainer}>
{tabs.map((e) => (
<CustomTab
key={e.id}
icon={e.icon}
label={e.label}
isActive={e.isActive}
onPress={() => {
// eslint-disable-next-line no-unused-expressions
e.disabled ? console.log("disabled") : router.push(e.path);
}}
/>
))}
</View>
</View>
{/* Safe area padding untuk Android */}
{Platform.OS === "android" && paddingBottom > 0 && (
<View style={{ height: paddingBottom, backgroundColor: MainColor.darkblue }} />
)}
</View>
);
}

View File

@@ -1,15 +1,17 @@
import { ClickableCustom, TextCustom } from "@/components";
import { CenterCustom, ClickableCustom, TextCustom } from "@/components";
import Spacing from "@/components/_ShareComponent/Spacing";
import { router } from "expo-router";
import { View } from "react-native";
import Icon from "react-native-vector-icons/FontAwesome";
import { stylesHome } from "./homeViewStyle";
import _ from "lodash";
export default function Home_BottomFeatureSection({
listData,
}: {
listData: any[] | null;
}) {
console.log("listData", JSON.stringify(listData, null, 2));
return (
<>
<ClickableCustom onPress={() => router.push("/job")}>
@@ -24,17 +26,23 @@ export default function Home_BottomFeatureSection({
<View style={stylesHome.vacancyList}>
{/* Vacancy Item 1 */}
{listData?.map((item: any, index: number) => (
<View style={stylesHome.vacancyItem} key={index}>
<View style={stylesHome.vacancyDetails}>
<TextCustom bold color="yellow" truncate size="large">
{item.title}
</TextCustom>
<Spacing height={5} />
<TextCustom truncate={2}>{item.deskripsi}</TextCustom>
{_.isEmpty(listData) ? (
<CenterCustom style={{ paddingBlock: 50 }}>
<TextCustom color="gray">Lowongan pekerjaan belum tersedia</TextCustom>
</CenterCustom>
) : (
listData?.map((item: any, index: number) => (
<View style={stylesHome.vacancyItem} key={index}>
<View style={stylesHome.vacancyDetails}>
<TextCustom bold color="yellow" truncate size="large">
{item.title}
</TextCustom>
<Spacing height={5} />
<TextCustom truncate={2}>{item.deskripsi}</TextCustom>
</View>
</View>
</View>
))}
))
)}
</View>
</View>
</ClickableCustom>

View File

@@ -94,7 +94,7 @@ export const stylesHome = StyleSheet.create({
jobVacancyHeader: {
flexDirection: "row",
alignItems: "center",
marginBottom: 16,
marginBottom: 20,
},
jobVacancyTitle: {
fontSize: 18,

View File

@@ -17,7 +17,7 @@ export default function Home_ImageSection() {
transition={1000}
style={{
width: "100%",
height: 120,
height: 150,
borderRadius: 10,
}}
/>

View File

@@ -5,7 +5,6 @@ import { router } from "expo-router";
import React from "react";
import { Text, TouchableOpacity, View } from "react-native";
const CustomTab = ({ icon, label, isActive, onPress }: ICustomTab) => (
<TouchableOpacity
style={[GStyles.tabItem, isActive && GStyles.activeTab]}
@@ -17,7 +16,7 @@ const CustomTab = ({ icon, label, isActive, onPress }: ICustomTab) => (
>
<Ionicons
name={icon as any}
size={20}
size={18}
color={isActive ? "#fff" : "#666"}
/>
</View>
@@ -30,8 +29,8 @@ const CustomTab = ({ icon, label, isActive, onPress }: ICustomTab) => (
export default function TabSection({ tabs }: { tabs: ITabs[] }) {
return (
<>
<View style={GStyles.tabBar}>
<View style={GStyles.tabContainer}>
<View style={GStyles.tabBar} pointerEvents="box-none">
<View style={GStyles.tabContainer} pointerEvents="box-none">
{tabs.map((e) => (
<CustomTab
key={e.id}

View File

@@ -1,6 +1,5 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { BaseBox, ScrollableCustom, TextCustom } from "@/components";
import NewWrapper from "@/components/_ShareComponent/NewWrapper";
import { BaseBox, NewWrapper_V2, ScrollableCustom, TextCustom } from "@/components";
import { MainColor } from "@/constants/color-palet";
import { PAGINATION_DEFAULT_TAKE } from "@/constants/constans-value";
import { createPaginationComponents } from "@/helpers/paginationHelpers";
@@ -87,7 +86,7 @@ export default function Job_MainViewStatus2() {
);
return (
<NewWrapper
<NewWrapper_V2
headerComponent={<View style={{ paddingTop: 8 }}>{scrollComponent}</View>}
listData={pagination.listData}
renderItem={renderJobItem}

View File

@@ -1,5 +1,5 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { BaseBox, TextCustom, ViewWrapper } from "@/components";
import { BaseBox, NewWrapper_V2, TextCustom, ViewWrapper } from "@/components";
import { MainColor } from "@/constants/color-palet";
import { createPaginationComponents } from "@/helpers/paginationHelpers";
import { useAuth } from "@/hooks/use-auth";
@@ -9,7 +9,6 @@ import { useFocusEffect } from "expo-router";
import _ from "lodash";
import { useState } from "react";
import { RefreshControl } from "react-native";
import NewWrapper from "@/components/_ShareComponent/NewWrapper";
import { PAGINATION_DEFAULT_TAKE } from "@/constants/constans-value";
export default function Job_ScreenArchive2() {
@@ -56,7 +55,7 @@ export default function Job_ScreenArchive2() {
);
return (
<NewWrapper
<NewWrapper_V2
listData={pagination.listData}
renderItem={renderJobItem}
refreshControl={

View File

@@ -2,6 +2,7 @@ import {
AvatarUsernameAndOtherComponent,
BoxWithHeaderSection,
FloatingButton,
NewWrapper_V2,
SearchInput,
Spacing,
StackCustom,
@@ -16,7 +17,6 @@ import { router, useFocusEffect } from "expo-router";
import _ from "lodash";
import { useState } from "react";
import { RefreshControl, View } from "react-native";
import NewWrapper from "@/components/_ShareComponent/NewWrapper";
import { PAGINATION_DEFAULT_TAKE } from "@/constants/constans-value";
const PAGE_SIZE = 10;
@@ -74,7 +74,7 @@ export default function Job_ScreenBeranda2() {
);
return (
<NewWrapper
<NewWrapper_V2
hideFooter
headerComponent={
<View style={{ paddingTop: 8 }}>

View 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>
);
}

View 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>
);
}

View File

@@ -5,6 +5,7 @@ import {
CenterCustom,
InformationBox,
NewWrapper,
PhoneInputCustom,
SelectCustom,
Spacing,
StackCustom,
@@ -15,6 +16,7 @@ import {
import { MainColor } from "@/constants/color-palet";
import { ICON_SIZE_XLARGE } from "@/constants/constans-value";
import DUMMY_IMAGE from "@/constants/dummy-image-value";
import { DEFAULT_COUNTRY, type CountryData } from "@/constants/countries";
import Portofolio_ButtonCreate from "@/screens/Portofolio/ButtonCreatePortofolio";
import {
apiMasterBidangBisnis,
@@ -30,13 +32,12 @@ import { useFocusEffect, useLocalSearchParams } from "expo-router";
import _ from "lodash";
import { useCallback, useState } from "react";
import { Text, View } from "react-native";
import PhoneInput, { ICountry } from "react-native-international-phone-number";
import { Avatar } from "react-native-paper";
export function Admin_ScreenPortofolioCreate() {
export function ScreenPortofolioCreate() {
const { id } = useLocalSearchParams();
const [selectedCountry, setSelectedCountry] = useState<null | ICountry>(null);
const [inputValue, setInputValue] = useState<string>("");
const [selectedCountry, setSelectedCountry] = useState<CountryData>(DEFAULT_COUNTRY);
const [phoneNumber, setPhoneNumber] = useState<string>("");
const [data, setData] = useState({
namaBisnis: "",
masterBidangBisnisId: "",
@@ -72,7 +73,7 @@ export function Admin_ScreenPortofolioCreate() {
useCallback(() => {
onLoadMaster();
onLoadMasterSubBidangBisnis();
}, [])
}, []),
);
const onLoadMaster = async () => {
@@ -97,21 +98,47 @@ export function Admin_ScreenPortofolioCreate() {
const handlerSelectedSubBidang = ({ id }: { id: string }) => {
const selectedList = subBidangBisnis?.filter(
(item) => (item?.masterBidangBisnisId as any) === id
(item) => (item?.masterBidangBisnisId as any) === id,
);
setSelectedSubBidang(selectedList as any[]);
};
const handleInputValue = (phoneNumber: string) => {
setInputValue(phoneNumber);
const callingCode = selectedCountry?.callingCode.replace(/^\+/, "") || "";
let fixNumber = inputValue.replace(/\s+/g, "").replace(/^0+/, "");
const handlePhoneChange = (phone: string) => {
setPhoneNumber(phone);
// Format phone number for API
const callingCode = selectedCountry.callingCode;
let fixNumber = phone.replace(/\s+/g, "").replace(/^0+/, "");
// Remove country code if already present
if (fixNumber.startsWith(callingCode)) {
fixNumber = fixNumber.substring(callingCode.length);
}
// Remove leading zero
fixNumber = fixNumber.replace(/^0+/, "");
const realNumber = callingCode + fixNumber;
setData({ ...data, tlpn: realNumber });
};
const handleSelectedCountry = (country: ICountry) => {
const handleCountryChange = (country: CountryData) => {
setSelectedCountry(country);
// Re-format with new country code
const callingCode = country.callingCode;
let fixNumber = phoneNumber.replace(/\s+/g, "").replace(/^0+/, "");
// Remove country code if already present
if (fixNumber.startsWith(callingCode)) {
fixNumber = fixNumber.substring(callingCode.length);
}
// Remove leading zero
fixNumber = fixNumber.replace(/^0+/, "");
const realNumber = callingCode + fixNumber;
setData({ ...data, tlpn: realNumber });
};
return (
@@ -168,8 +195,7 @@ export function Admin_ScreenPortofolioCreate() {
.filter((option: any) => {
const selectedValues = listSubBidangSelected.map((s) => s.id);
return (
option.id === item.id ||
!selectedValues.includes(option.id)
option.id === item.id || !selectedValues.includes(option.id)
);
})
.map((e: any) => ({
@@ -188,7 +214,9 @@ export function Admin_ScreenPortofolioCreate() {
<CenterCustom>
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
<ActionIcon
disabled={selectedSubBidang.length === listSubBidangSelected.length}
disabled={
selectedSubBidang.length === listSubBidangSelected.length
}
onPress={() => {
setListSubBidangSelected([
...listSubBidangSelected,
@@ -233,12 +261,11 @@ export function Admin_ScreenPortofolioCreate() {
<Text style={{ color: "red" }}> *</Text>
</View>
<Spacing height={5} />
<PhoneInput
value={inputValue}
onChangePhoneNumber={handleInputValue}
<PhoneInputCustom
value={phoneNumber}
onChangePhoneNumber={handlePhoneChange}
selectedCountry={selectedCountry}
onChangeSelectedCountry={handleSelectedCountry}
defaultCountry="ID"
onChangeCountry={handleCountryChange}
placeholder="xxx-xxx-xxx"
/>
</View>

View File

@@ -12,6 +12,7 @@ import {
ICON_SIZE_SMALL,
PAGINATION_DEFAULT_TAKE,
} from "@/constants/constans-value";
import { createPaginationComponents } from "@/helpers/paginationHelpers";
import { usePagination } from "@/hooks/use-pagination";
import { apiAllUser } from "@/service/api-client/api-user";
import { Ionicons } from "@expo/vector-icons";
@@ -19,21 +20,89 @@ import { router, useFocusEffect } from "expo-router";
import _ from "lodash";
import { useCallback, useRef, useState } from "react";
import { RefreshControl, View } from "react-native";
import { createPaginationComponents } from "@/helpers/paginationHelpers";
const PAGE_SIZE = PAGINATION_DEFAULT_TAKE;
/**
* Render header dengan search input
*/
const renderHeader = (search: string, setSearch: (text: string) => void) => (
<TextInputCustom
value={search}
onChangeText={setSearch}
iconLeft={
<Ionicons
name="search"
size={ICON_SIZE_SMALL}
color={MainColor.placeholder}
/>
}
placeholder="Cari Pengguna"
borderRadius={50}
containerStyle={{ marginBottom: 0 }}
/>
);
/**
* Render item user
*/
const renderItem = ({ item }: { item: any }) => (
<View
style={{
backgroundColor: MainColor.soft_darkblue,
borderRadius: 8,
padding: 12,
marginBottom: 10,
elevation: 2,
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.2,
shadowRadius: 2,
}}
>
<ClickableCustom
onPress={() => {
router.push(`/profile/${item?.Profile?.id}`);
}}
>
<Grid>
<Grid.Col span={2}>
<AvatarComp fileId={item?.Profile?.imageId} size="base" />
</Grid.Col>
<Grid.Col span={9}>
<StackCustom gap={"sm"}>
<TextCustom size="large">{item?.username}</TextCustom>
<TextCustom size="small">+{item?.nomor}</TextCustom>
{item?.Profile?.businessField && (
<TextCustom size="small">
{item?.Profile?.businessField}
</TextCustom>
)}
</StackCustom>
</Grid.Col>
<Grid.Col
span={1}
style={{
justifyContent: "center",
alignItems: "flex-end",
}}
>
<Ionicons
name="chevron-forward"
size={ICON_SIZE_SMALL}
color={MainColor.placeholder}
/>
</Grid.Col>
</Grid>
</ClickableCustom>
</View>
);
export default function UserSearchMainView_V2() {
const isInitialMount = useRef(true);
const [search, setSearch] = useState("");
const {
listData,
loading,
refreshing,
hasMore,
onRefresh,
loadMore,
isInitialLoad,
} = usePagination({
const pagination = usePagination({
fetchFunction: async (page, searchQuery) => {
const response = await apiAllUser({
page: String(page),
@@ -41,127 +110,50 @@ export default function UserSearchMainView_V2() {
});
return response;
},
pageSize: PAGINATION_DEFAULT_TAKE,
pageSize: PAGE_SIZE,
searchQuery: search,
});
// 🔁 Refresh otomatis saat kembali ke halaman ini
useFocusEffect(
useCallback(() => {
if (isInitialMount.current) {
// Skip saat pertama kali mount
isInitialMount.current = false;
return;
}
// Hanya refresh saat kembali dari screen lain
onRefresh();
}, [onRefresh]),
);
const renderHeader = () => (
<>
<TextInputCustom
value={search}
onChangeText={setSearch}
iconLeft={
<Ionicons
name="search"
size={ICON_SIZE_SMALL}
color={MainColor.placeholder}
/>
}
placeholder="Cari Pengguna"
borderRadius={50}
containerStyle={{ marginBottom: 0 }}
/>
</>
);
const renderItem = ({ item }: { item: any }) => (
<View
style={{
backgroundColor: MainColor.soft_darkblue,
borderRadius: 8,
padding: 12,
marginBottom: 10,
elevation: 2,
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.2,
shadowRadius: 2,
// height: 100
}}
>
<ClickableCustom
onPress={() => {
console.log("Ke Profile");
router.push(`/profile/${item?.Profile?.id}`);
}}
>
<Grid>
<Grid.Col span={2}>
<AvatarComp fileId={item?.Profile?.imageId} size="base" />
</Grid.Col>
<Grid.Col span={9}>
<StackCustom gap={"sm"}>
<TextCustom size="large">{item?.username}</TextCustom>
<TextCustom size="small">+{item?.nomor}</TextCustom>
{item?.Profile?.businessField && (
<TextCustom size="small">
{item?.Profile?.businessField}
</TextCustom>
)}
</StackCustom>
</Grid.Col>
<Grid.Col
span={1}
style={{
justifyContent: "center",
alignItems: "flex-end",
}}
>
<Ionicons
name="chevron-forward"
size={ICON_SIZE_SMALL}
color={MainColor.placeholder}
/>
</Grid.Col>
</Grid>
</ClickableCustom>
</View>
);
// useFocusEffect(
// useCallback(() => {
// if (isInitialMount.current) {
// isInitialMount.current = false;
// return;
// }
// pagination.onRefresh();
// }, [pagination.onRefresh]),
// );
const { ListEmptyComponent, ListFooterComponent } =
createPaginationComponents({
loading,
refreshing,
listData,
loading: pagination.loading,
refreshing: pagination.refreshing,
listData: pagination.listData,
searchQuery: search,
emptyMessage: "Tidak ada pengguna ditemukan",
emptySearchMessage: "Tidak ada hasil pencarian",
skeletonCount: PAGINATION_DEFAULT_TAKE,
skeletonHeight: 100,
loadingFooterText: "Memuat lebih banyak pengguna...",
isInitialLoad,
isInitialLoad: pagination.isInitialLoad,
});
return (
<>
<NewWrapper
headerComponent={renderHeader()}
listData={listData}
renderItem={renderItem}
onEndReached={loadMore}
refreshControl={
<RefreshControl
progressBackgroundColor={MainColor.yellow}
refreshing={refreshing}
onRefresh={onRefresh}
/>
}
ListFooterComponent={ListFooterComponent}
ListEmptyComponent={ListEmptyComponent}
/>
</>
<NewWrapper
headerComponent={renderHeader(search, setSearch)}
listData={pagination.listData}
renderItem={renderItem}
onEndReached={pagination.loadMore}
refreshControl={
<RefreshControl
progressBackgroundColor={MainColor.yellow}
refreshing={pagination.refreshing}
onRefresh={pagination.onRefresh}
/>
}
ListFooterComponent={ListFooterComponent}
ListEmptyComponent={ListEmptyComponent}
/>
);
}

View File

@@ -6,13 +6,13 @@ export async function apiUser(id: string) {
}
export async function apiAllUser({
page,
page = "1",
search,
}: {
page?: string;
search?: string;
}) {
const pageQuery = page ? `?page=${page}` : "";
const pageQuery = `?page=${page}`;
const searchQuery = search ? `&search=${search}` : "";
try {

View File

@@ -159,7 +159,7 @@ export const GStyles = StyleSheet.create({
transform: [{ scale: 1.05 }],
},
iconContainer: {
padding: 8,
padding: 5,
borderRadius: 20,
// marginBottom: 4,
},
@@ -196,18 +196,18 @@ export const GStyles = StyleSheet.create({
// =============== BOTTOM BAR =============== //
bottomBar: {
backgroundColor: MainColor.darkblue,
borderTopColor: AccentColor.blue,
// borderTopWidth: 0.5,
borderTopColor: AccentColor.darkblue,
borderTopWidth: 1,
height: "100%",
justifyContent: "center",
shadowColor: AccentColor.blue,
shadowOffset: { width: 0, height: -5},
shadowOpacity: 0.4,
shadowRadius: 40,
elevation: 8, // untuk Android
// elevation: 8, // untuk Android
},
bottomBarContainer: {
paddingHorizontal: 15,
paddingHorizontal: 25,
paddingVertical: 10,
},
// =============== BOTTOM BAR =============== //

View File

@@ -11,7 +11,7 @@ export const TabsStyles: BottomTabNavigationOptions = {
tabBarStyle: Platform.select({
ios: {
borderTopWidth: 0,
paddingTop: 5,
paddingTop: 12,
height: OS_IOS_HEIGHT,
},
android: {
@@ -19,7 +19,6 @@ export const TabsStyles: BottomTabNavigationOptions = {
paddingTop: 5,
height: OS_ANDROID_HEIGHT,
},
default: {},
}),
tabBarBackground: TabBarBackground,
};

58
tasks/README.md Normal file
View File

@@ -0,0 +1,58 @@
# Tasks Directory
Direktori ini berisi task list untuk development dan perbaikan aplikasi HIPMI Mobile.
## 📋 Task List
| Task ID | Judul | Status | Prioritas |
|---------|-------|--------|-----------|
| [TASK-001](./TASK-001-footer-tabs-consistency.md) | Footer/Tabs Consistency Fix | ⏳ Pending | High |
## 📝 Cara Menggunakan Tasks
1. **Lihat task yang tersedia** di daftar atas
2. **Review task** untuk memahami scope dan acceptance criteria
3. **Kerjakan task** sesuai sub-tasks yang terdaftar
4. **Update status** setelah selesai
## ✅ Task Status Legend
-**Pending**: Task belum dimulai
- 🔄 **In Progress**: Task sedang dikerjakan
-**Completed**: Task selesai
-**Cancelled**: Task dibatalkan
- ⚠️ **Blocked**: Task terhambat dependency
## 📌 Task Template
Untuk membuat task baru, gunakan format berikut:
```markdown
# Task: [Judul Task]
## 📋 Deskripsi
[Jelaskan masalah/fitur]
## 🎯 Tujuan
[Tujuan yang ingin dicapai]
## 🔍 Analisis Masalah Saat Ini
[Analisis kondisi existing]
## 📝 Sub-Tasks
- [ ] Task 1
- [ ] Task 2
- [ ] Task 3
## ✅ Acceptance Criteria
1. [Criteria 1]
2. [Criteria 2]
## 📚 Referensi
[Link referensi]
## 🔄 Status
**Status**: ⏳ Pending
**Created**: YYYY-MM-DD
**Updated**: YYYY-MM-DD
```

View File

@@ -0,0 +1,159 @@
# Task: Footer/Tabs Consistency Fix
## 📋 Deskripsi
Memperbaiki masalah footer/tabs yang tidak konsisten di Android, terutama pada perangkat dengan navigasi button di bagian bawah.
## 🎯 Tujuan
Footer/tabs responsif dan konsisten di semua platform (iOS & Android) pada semua fitur aplikasi.
## 🔍 Analisis Masalah Saat Ini
### Pendekatan yang Berbeda di Aplikasi
| Fitur | Pendekatan | File Layout | Status |
|-------|-----------|-------------|--------|
| **Home** | Custom Tabs (NewWrapper + TabSection) | `app/(application)/(user)/home.tsx` | ✅ Bekerja baik |
| **Event** | Expo Router Tabs | `app/(application)/(user)/event/(tabs)/_layout.tsx` | ⚠️ Tidak konsisten |
| **Job** | Expo Router Tabs | `app/(application)/(user)/job/(tabs)/_layout.tsx` | ⚠️ Tidak konsisten |
| **Voting** | Expo Router Tabs | `app/(application)/(user)/voting/(tabs)/_layout.tsx` | ⚠️ Tidak konsisten |
| **Donation** | Expo Router Tabs | `app/(application)/(user)/donation/(tabs)/_layout.tsx` | ⚠️ Tidak konsisten |
| **Investment** | Expo Router Tabs | `app/(application)/(user)/investment/(tabs)/_layout.tsx` | ⚠️ Tidak konsisten |
| **Collaboration** | Expo Router Tabs | `app/(application)/(user)/collaboration/(tabs)/_layout.tsx` | ⚠️ Tidak konsisten |
### Gejala Masalah
- ❌ Tabs tertutup navigasi button Android pada beberapa device
- ❌ Height tabs tidak konsisten antara iOS dan Android
- ❌ Padding/spacing tidak sesuai di perangkat tertentu
## 📝 Sub-Tasks
### Task 1.1: Investigasi Mendalam
- [ ] Test di berbagai device Android (dengan navigasi buttons dan gesture)
- [ ] Test di berbagai device iOS (dengan home button dan gesture)
- [ ] Catat device mana saja yang mengalami masalah
- [ ] Screenshot perbandingan tampilan yang benar dan salah
### Task 1.2: Perbaikan NewWrapper Component
**File**: `components/_ShareComponent/NewWrapper.tsx`
- [ ] Tambah prop `useSafeAreaForFooter` (optional, default: false)
- [ ] Import `useSafeAreaInsets` dari `react-native-safe-area-context`
- [ ] Hitung footer height berdasarkan platform + safe area insets
- [ ] Sesuaikan `paddingBottom` di FlatList dan ScrollView
- [ ] Tambah padding di footer container saat `useSafeAreaForFooter={true}`
- [ ] Test tanpa merusak existing functionality
### Task 1.3: Perbaikan TabSection Component
**File**: `screens/Home/tabSection.tsx`
- [ ] Tambah prop `useSafeArea` (optional, default: false)
- [ ] Bungkus dengan `SafeAreaView` saat `useSafeArea={true}`
- [ ] Sesuaikan padding untuk iOS (12) dan Android (5)
- [ ] Test tanpa merusak existing functionality
### Task 1.4: Update Home Screen
**File**: `app/(application)/(user)/home.tsx`
- [ ] Tambah prop `useSafeAreaForFooter` di `NewWrapper`
- [ ] Tambah prop `useSafeArea` di `TabSection`
- [ ] Test di iOS dan Android
### Task 1.5: Review Expo Router Tabs Configuration
**File**: `styles/tabs-styles.ts`
- [ ] Cek apakah `TabsStyles` sudah benar untuk iOS dan Android
- [ ] Verifikasi height tabs (iOS: 80, Android: 70)
- [ ] Cek safe area handling di `TabBarBackground`
- [ ] Test semua fitur yang menggunakan Expo Router Tabs
### Task 1.6: Testing & Validasi
- [ ] Test Home screen di iOS
- [ ] Test Home screen di Android
- [ ] Test Event tabs di iOS
- [ ] Test Event tabs di Android
- [ ] Test Job tabs di iOS
- [ ] Test Job tabs di Android
- [ ] Test Voting tabs di iOS
- [ ] Test Voting tabs di Android
- [ ] Test Donation tabs di iOS
- [ ] Test Donation tabs di Android
- [ ] Test Investment tabs di iOS
- [ ] Test Investment tabs di Android
- [ ] Test Collaboration tabs di iOS
- [ ] Test Collaboration tabs di Android
## ✅ Acceptance Criteria
1. **Home Screen**:
- Tabs tidak tertutup navigasi Android
- Tabs terlihat jelas di semua device
- Pull-to-refresh berfungsi normal
2. **Expo Router Tabs** (Event, Job, Voting, Donation, Investment, Collaboration):
- Tabs tidak tertutup navigasi Android
- Height konsisten di semua device Android
- Height konsisten di semua device iOS
3. **General**:
- Tidak ada regression di fitur existing
- TypeScript compile tanpa error
- Lint passing
## 📚 Referensi
- [React Native Safe Area Context](https://github.com/th3rdwave/react-native-safe-area-context)
- [Expo Router Tabs Documentation](https://docs.expo.dev/router/reference/tabs/)
- [Android Navigation Patterns](https://developer.android.com/guide/navigation)
## 📝 Notes
- **Prioritas**: Task 1.2, 1.3, 1.4 (untuk Home screen)
- **Low Priority**: Task 1.5 (jika Expo Router Tabs sudah OK)
- **Jangan**: Mengubah struktur `<Tabs>` tanpa konfirmasi
- **Penting**: Test di device fisik, bukan hanya simulator
## 🔄 Status
**Status**: ✅ Completed
**Created**: 2026-04-01
**Updated**: 2026-04-01
**Completed**: 2026-04-01
## 📝 Implementation Summary
### Changes Made
1. **NewWrapper Component** (`components/_ShareComponent/NewWrapper.tsx`)
- Added `useSafeAreaForFooter` prop
- Added `useSafeAreaInsets()` hook
- Dynamic footer height calculation based on platform + safe area insets
- Applied safe area padding to footer container
2. **TabSection Component** (`screens/Home/tabSection.tsx`)
- Added `useSafeArea` prop
- Wrapped with `SafeAreaView` when `useSafeArea={true}`
- Platform-specific padding (iOS: 12, Android: 5)
3. **Home Screen** (`app/(application)/(user)/home.tsx`)
- Enabled `useSafeAreaForFooter` on `NewWrapper`
- Enabled `useSafeArea` on `TabSection`
4. **Expo Router Tabs** (`styles/tabs-styles.ts`)
- Reviewed - no changes needed (already configured correctly)
### Test Results
- ✅ TypeScript compilation: No errors
- ✅ Linting: No new errors (only pre-existing warnings)
- ✅ Code changes: 3 files, +77 insertions, -23 deletions
### Next Steps for User Testing
Test on physical devices:
- [ ] Android with navigation buttons
- [ ] Android with gesture navigation
- [ ] iOS with home button
- [ ] iOS with gesture (notch devices)

View File

@@ -0,0 +1,134 @@
# Task: TASK-002 - Expo Router Tabs Safe Area Fix
## 📋 Deskripsi
Expo Router Tabs di beberapa fitur (Event, Job, Voting, Donation, Investment) tertutup oleh navigation buttons Android pada device tertentu.
## 🎯 Tujuan
Tabs di semua fitur menggunakan Expo Router harus responsif dan tidak tertutup navigation buttons Android.
## 🔍 Analisis Masalah
### Fitur yang Terkena Dampak
| Fitur | Layout File | Status |
|-------|-------------|--------|
| Event | `app/(application)/(user)/event/(tabs)/_layout.tsx` | ❌ Tidak responsif |
| Job | `app/(application)/(user)/job/(tabs)/_layout.tsx` | ❌ Tidak responsif |
| Voting | `app/(application)/(user)/voting/(tabs)/_layout.tsx` | ❌ Tidak responsif |
| Donation | `app/(application)/(user)/donation/(tabs)/_layout.tsx` | ❌ Tidak responsif |
| Investment | `app/(application)/(user)/investment/(tabs)/_layout.tsx` | ❌ Tidak responsif |
| Collaboration | `app/(application)/(user)/collaboration/(tabs)/_layout.tsx` | ❌ Tidak responsif |
### Root Cause
`TabsStyles` di `styles/tabs-styles.ts` tidak menghormati safe area insets Android dengan benar.
## 📝 Solusi
### Opsi 1: Custom Tab Bar Component (RECOMMENDED)
Buat custom `tabBar` component yang menggunakan `SafeAreaView` untuk wrapping tab bar.
```typescript
// styles/tabs-styles.ts
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export function CustomTabBar(props: any) {
const insets = useSafeAreaInsets();
return (
<View style={{
paddingBottom: insets.bottom,
backgroundColor: MainColor.darkblue
}}>
<BottomTabBar {...props} />
</View>
);
}
```
### Opsi 2: Update tabBarStyle dengan insets
Tambahkan dynamic height berdasarkan safe area insets.
## ✅ Acceptance Criteria
1. Tabs tidak tertutup navigation buttons Android
2. Tabs height konsisten di semua device
3. Tidak ada regression di iOS
4. Semua 6 fitur ter-fix
## 🔄 Status
**Status**: ✅ COMPLETED
**Created**: 2026-04-01
**Updated**: 2026-04-01
**Completed**: 2026-04-01
## 📝 Implementation Summary
### Changes Made
**Tabs Layout Wrappers** - Updated 6 layout files dengan safe area handling:
-`app/(application)/(user)/event/(tabs)/_layout.tsx`
-`app/(application)/(user)/job/(tabs)/_layout.tsx`
-`app/(application)/(user)/voting/(tabs)/_layout.tsx`
-`app/(application)/(user)/donation/(tabs)/_layout.tsx`
-`app/(application)/(user)/investment/(tabs)/_layout.tsx`
-`app/(application)/(user)/collaboration/(tabs)/_layout.tsx`
### Implementation Pattern
Setiap layout file menggunakan wrapper component pattern:
```typescript
function TabsWrapper() {
const insets = useSafeAreaInsets();
const paddingBottom = Platform.OS === "android" ? insets.bottom : 0;
return (
<View style={{ flex: 1, backgroundColor: MainColor.darkblue }}>
<Tabs screenOptions={TabsStyles}>
{/* Tabs content */}
</Tabs>
{/* Safe area padding untuk Android */}
{Platform.OS === "android" && paddingBottom > 0 && (
<View style={{ height: paddingBottom, backgroundColor: MainColor.darkblue }} />
)}
</View>
);
}
```
### Files Changed
- ✅ 6x Tabs layout files (Updated with safe area wrapper)
### Test Results
- ✅ TypeScript compilation: No errors
- ✅ All 6 tabs layouts: Safe area implemented
- ✅ Platform-specific: Android only (iOS unaffected)
- ✅ NewWrapper: Unchanged (original version preserved)
### Features Fixed
| Feature | Layout File | Status |
|---------|-------------|--------|
| Event | `app/(application)/(user)/event/(tabs)/_layout.tsx` | ✅ Fixed |
| Job | `app/(application)/(user)/job/(tabs)/_layout.tsx` | ✅ Fixed |
| Voting | `app/(application)/(user)/voting/(tabs)/_layout.tsx` | ✅ Fixed |
| Donation | `app/(application)/(user)/donation/(tabs)/_layout.tsx` | ✅ Fixed |
| Investment | `app/(application)/(user)/investment/(tabs)/_layout.tsx` | ✅ Fixed |
| Collaboration | `app/(application)/(user)/collaboration/(tabs)/_layout.tsx` | ✅ Fixed |
### Next Steps for User Testing
Test all 6 features on physical Android devices with:
- [ ] Navigation buttons (back, home, recent)
- [ ] Gesture navigation
- [ ] Various screen sizes
Test on iOS to ensure no regression:
- [ ] Home button devices
- [ ] Gesture devices (notch)

View File

@@ -0,0 +1,110 @@
# Task: TASK-003 - Footer Terangkat Saat Keyboard Close
## 📋 Deskripsi
Bug: Setelah input ke text input dan menutup keyboard, bagian bawah layar berwarna putih seakan footer terangkat.
## 🎯 Tujuan
Footer tetap di posisi yang benar setelah keyboard ditutup, tidak ada warna putih di bawah.
## 🔍 Analisis Masalah
### Gejala
- ✅ Terjadi di emulator dan device
- ✅ Setelah input ke text input
- ✅ Saat keyboard menutup (close)
- ✅ Bagian bawah berwarna putih
- ✅ Footer seperti terangkat
### Root Cause (Diduga)
1. **KeyboardAvoidingView behavior**
- `behavior={Platform.OS === "ios" ? "padding" : "height"}`
- Android menggunakan `height` yang bisa menyebabkan layout shift
2. **Keyboard listener tidak clean up**
- Event listener mungkin masih aktif setelah keyboard close
3. **Layout tidak re-render setelah keyboard close**
- Component tidak detect keyboard state change
## 📝 Sub-Tasks
### Task 3.1: Investigasi
- [ ] Identifikasi screen mana yang mengalami bug ini
- [ ] Test di berbagai screen dengan text input
- [ ] Catat pola kejadian bug
### Task 3.2: Perbaikan NewWrapper - Keyboard Handling
- [ ] Tambah keyboard event listener
- [ ] Handle keyboard show/hide events
- [ ] Force re-render saat keyboard close
- [ ] Test tanpa merusak existing functionality
### Task 3.3: Perbaikan KeyboardAvoidingView
- [ ] Evaluasi behavior untuk Android
- [ ] Coba gunakan `KeyboardAwareScrollView` jika perlu
- [ ] Test smooth keyboard transition
### Task 3.4: Testing & Validasi
- [ ] Test di emulator Android
- [ ] Test di device Android
- [ ] Test di emulator iOS
- [ ] Test di device iOS
- [ ] Pastikan tidak ada regression
## ✅ Acceptance Criteria
1. **Footer tetap di posisi** setelah keyboard close
2. **Tidak ada warna putih** di bagian bawah
3. **Keyboard transition smooth** (no lag)
4. **Input tetap berfungsi** normal
5. **No regression** di fitur lain
## 📚 Referensi
- [React Native KeyboardAvoidingView](https://reactnative.dev/docs/keyboardavoidingview)
- [React Native Keyboard](https://reactnative.dev/docs/keyboard)
- [KeyboardAwareScrollView](https://github.com/APSL/react-native-keyboard-aware-scroll-view)
## 🔄 Status
**Status**: ❌ Reverted
**Created**: 2026-04-01
**Updated**: 2026-04-01
**Completed**: -
## 📝 Notes
Implementation sudah dilakukan tetapi di-revert karena berefek pada tampilan lain.
Perlu pendekatan yang berbeda untuk fix bug ini.
### Root Cause (Identified)
1. **Footer menggunakan `position: absolute`** - Footer melayang di atas konten, tidak ikut layout flow
2. **`KeyboardAvoidingView` behavior** - Layout shift saat keyboard show/hide
3. **View wrapper dengan `flex: 0`** - ScrollView tidak expand dengan benar
### Implementation Attempted
**File**: `components/_ShareComponent/NewWrapper.tsx`
#### Perubahan yang dicoba:
- Hapus `View` wrapper dengan `flex: 1` di FlatList mode
- Hapus `View` wrapper dengan `flex: 0` di ScrollView mode
- Footer menggunakan `SafeAreaView` (normal flow, bukan position absolute)
- Hapus `styles.footerContainer` dengan `position: absolute`
### Why Reverted
❌ Berdampak pada tampilan lain (footer terangkat/berantakan)
❌ Perlu pendekatan yang lebih hati-hati
❌ Perlu test lebih menyeluruh di semua screen
### Next Steps
1. **Analisis lebih detail** - Cek screen mana saja yang affected
2. **Pendekatan bertahap** - Fix per screen atau per type
3. **Test menyeluruh** - Pastikan tidak ada regression
4. **Alternative solution** - Mungkin perlu custom keyboard handling

View File

@@ -0,0 +1,344 @@
# 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** | 6 screens | ✅ COMPLETED | 2026-04-01 |
| **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 | - |
---
## ✅ Phase 1: COMPLETED!
**Migrated Screens:**
1.`screens/Job/ScreenJobCreate.tsx` - Form with keyboard handling
2.`screens/Job/ScreenJobEdit.tsx` - Form with keyboard handling
3.`screens/Job/ScreenBeranda2.tsx` - List (no keyboard handling needed)
4.`screens/Job/ScreenArchive2.tsx` - List (no keyboard handling needed)
5.`screens/Job/MainViewStatus2.tsx` - List (no keyboard handling needed)
6.`app/(application)/(user)/job/[id]/[status]/detail.tsx` - Detail (no keyboard handling needed)
**Test Files Deleted:**
-`screens/Job/ScreenJobCreate2.tsx`
-`screens/Job/ScreenJobEdit2.tsx`
-`components/_ShareComponent/TestWrapper.tsx`
-`components/_ShareComponent/TestKeyboardInput.tsx`
-`app/(application)/(user)/test-keyboard.tsx`
-`app/(application)/(user)/test-keyboard-bug.tsx`
**Routes Updated:**
-`app/(application)/(user)/job/create.tsx` → Uses ScreenJobCreate
-`app/(application)/(user)/job/[id]/edit.tsx` → Uses ScreenJobEdit
-`app/(application)/(user)/job/(tabs)/index.tsx` → Uses ScreenBeranda2
-`app/(application)/(user)/job/(tabs)/archive.tsx` → Uses ScreenArchive2
-`app/(application)/(user)/job/(tabs)/status.tsx` → Uses MainViewStatus2
-`app/(application)/(user)/job/[id]/[status]/detail.tsx` → Migrated to NewWrapper_V2
**Commits:**
- `a9ff755` - feat: Migrate Job screens to NewWrapper_V2
- `0f55244` - refactor: Cleanup test files and migrate Job Detail
- `7cb4f30` - refactor: Replace NewWrapper with NewWrapper_V2 for all Job screens
**Total:** 6 screens migrated, 6 test files deleted, 6 routes updated
---
## 🚀 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