Compare commits
41 Commits
amalia/25-
...
join
| Author | SHA1 | Date | |
|---|---|---|---|
| e2ffef1085 | |||
| cb2a57ee8e | |||
| f3b677f847 | |||
| 6ffe599ad0 | |||
| 4a92def490 | |||
| 0bad792ce8 | |||
| 6c9623954c | |||
| f39d5a4c85 | |||
| 6021d17b5a | |||
| 2d86a77a48 | |||
| b7165c5990 | |||
| 7dc51bd2b9 | |||
| de5ad545a7 | |||
| 47cb146c5a | |||
| 8b8ea61a13 | |||
| 5dac451754 | |||
| ccf8ee1caf | |||
| 887e787a99 | |||
| 772551a917 | |||
| 555b9e4037 | |||
| d4b4db4251 | |||
| 17d92cba25 | |||
| e1b62be6da | |||
| b2b125c410 | |||
| 1cfecbbdd5 | |||
| 21006e8eee | |||
| 91231d60e4 | |||
| 7174e27be1 | |||
| 9d4b931aa6 | |||
| 166d8f1c16 | |||
| 7060a2d165 | |||
| d6217aecf1 | |||
| 608381673f | |||
| 3cc7f76346 | |||
| 868b712fbb | |||
| a53b99b39d | |||
| 25d521f013 | |||
| aee0823cb1 | |||
| 2a0e1f4c1f | |||
| ef08c821fa | |||
| 7729dc38f8 |
33
CLAUDE.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
**Desa+** is a React Native (Expo) mobile app for village administration — managing announcements, projects, discussions, members, divisions, and documents. Primary platforms are Android and iOS.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run start # Start Expo dev server
|
||||||
|
npm run android # Run on Android
|
||||||
|
npm run ios # Run on iOS
|
||||||
|
npm run lint # Expo lint
|
||||||
|
npm run test # Jest tests
|
||||||
|
npm run build:android # Production Android build via EAS (bumps version first)
|
||||||
|
```
|
||||||
|
|
||||||
|
Run a single test file:
|
||||||
|
```bash
|
||||||
|
bunx jest path/to/test.tsx --no-coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
> Project uses **Bun** as the package manager (`bun.lock` present). Use `bun add` / `bunx` instead of `npm install` / `npx`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
See @docs/ARCHITECTURE.md
|
||||||
|
|
||||||
|
## Key Conventions
|
||||||
|
|
||||||
|
See @docs/CONVENTIONS.md
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Desa+
|
# Desa+
|
||||||
|
|
||||||
Desa+ adalah aplikasi mobile berbasis React Native yang dikembangkan dengan Expo untuk membantu pengelolaan dan komunikasi di lingkungan desa/kelurahan. Aplikasi ini menyediakan berbagai fitur untuk memudahkan administrasi desa, komunikasi antar warga, dan pengelolaan informasi penting.
|
Desa+ (Desa Plus) adalah aplikasi mobile berbasis React Native yang dikembangkan dengan Expo untuk membantu pengelolaan dan komunikasi di lingkungan desa/kelurahan. Aplikasi ini menyediakan berbagai fitur untuk memudahkan administrasi desa, komunikasi antar warga, dan pengelolaan informasi penting.
|
||||||
|
|
||||||
## Fitur Utama
|
## Fitur Utama
|
||||||
|
|
||||||
|
|||||||
77
__tests__/ErrorBoundary-test.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, fireEvent } from '@testing-library/react-native';
|
||||||
|
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||||
|
|
||||||
|
// Komponen yang sengaja throw error saat render
|
||||||
|
const BrokenComponent = () => {
|
||||||
|
throw new Error('Test error boundary!');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Komponen normal
|
||||||
|
const NormalComponent = () => <></>;
|
||||||
|
|
||||||
|
// Suppress React's error boundary console output selama test
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
(console.error as jest.Mock).mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ErrorBoundary', () => {
|
||||||
|
it('merender children dengan normal jika tidak ada error', () => {
|
||||||
|
// Tidak boleh throw dan tidak menampilkan teks error
|
||||||
|
const { queryByText } = render(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<NormalComponent />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
expect(queryByText('Terjadi Kesalahan')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('menampilkan UI fallback ketika child throw error', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<BrokenComponent />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
expect(getByText('Terjadi Kesalahan')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('menampilkan pesan error yang dilempar', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<BrokenComponent />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
expect(getByText('Test error boundary!')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merender custom fallback jika prop fallback diberikan', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<ErrorBoundary fallback={<></>}>
|
||||||
|
<BrokenComponent />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
// Custom fallback fragment kosong — pastikan teks default tidak muncul
|
||||||
|
expect(() => getByText('Terjadi Kesalahan')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mereset error state saat tombol Coba Lagi ditekan', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<BrokenComponent />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
|
||||||
|
const button = getByText('Coba Lagi');
|
||||||
|
expect(button).toBeTruthy();
|
||||||
|
|
||||||
|
// Tekan tombol reset — hasError kembali false, BrokenComponent throw lagi
|
||||||
|
// sehingga fallback muncul kembali (membuktikan reset berjalan)
|
||||||
|
fireEvent.press(button);
|
||||||
|
expect(getByText('Terjadi Kesalahan')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -92,8 +92,8 @@ android {
|
|||||||
applicationId 'mobiledarmasaba.app'
|
applicationId 'mobiledarmasaba.app'
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 6
|
versionCode 16
|
||||||
versionName "1.0.2"
|
versionName "2.1.0"
|
||||||
}
|
}
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
debug {
|
debug {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 904 B |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 5.2 KiB |
@@ -1,9 +1,9 @@
|
|||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
|
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
|
||||||
<item name="android:windowOptOutEdgeToEdgeEnforcement" tools:targetApi="35">true</item>
|
|
||||||
<item name="colorPrimary">@color/colorPrimary</item>
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
<item name="android:statusBarColor">#ffffff</item>
|
<item name="android:statusBarColor">#ffffff</item>
|
||||||
|
<item name="android:windowOptOutEdgeToEdgeEnforcement" tools:targetApi="35">true</item>
|
||||||
</style>
|
</style>
|
||||||
<style name="Theme.App.SplashScreen" parent="Theme.SplashScreen">
|
<style name="Theme.App.SplashScreen" parent="Theme.SplashScreen">
|
||||||
<item name="windowSplashScreenBackground">@color/splashscreen_background</item>
|
<item name="windowSplashScreenBackground">@color/splashscreen_background</item>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export default {
|
|||||||
expo: {
|
expo: {
|
||||||
name: "Desa+",
|
name: "Desa+",
|
||||||
slug: "mobile-darmasaba",
|
slug: "mobile-darmasaba",
|
||||||
version: "2.0.5", // Versi aplikasi (App Store)
|
version: "2.1.0", // Versi aplikasi (App Store)
|
||||||
jsEngine: "jsc",
|
jsEngine: "jsc",
|
||||||
orientation: "portrait",
|
orientation: "portrait",
|
||||||
icon: "./assets/images/logo-icon-small.png",
|
icon: "./assets/images/logo-icon-small.png",
|
||||||
@@ -14,7 +14,7 @@ export default {
|
|||||||
ios: {
|
ios: {
|
||||||
supportsTablet: true,
|
supportsTablet: true,
|
||||||
bundleIdentifier: "mobiledarmasaba.app",
|
bundleIdentifier: "mobiledarmasaba.app",
|
||||||
buildNumber: "7",
|
buildNumber: "9",
|
||||||
infoPlist: {
|
infoPlist: {
|
||||||
ITSAppUsesNonExemptEncryption: false,
|
ITSAppUsesNonExemptEncryption: false,
|
||||||
CFBundleDisplayName: "Desa+"
|
CFBundleDisplayName: "Desa+"
|
||||||
@@ -23,7 +23,7 @@ export default {
|
|||||||
},
|
},
|
||||||
android: {
|
android: {
|
||||||
package: "mobiledarmasaba.app",
|
package: "mobiledarmasaba.app",
|
||||||
versionCode: 15,
|
versionCode: 16,
|
||||||
adaptiveIcon: {
|
adaptiveIcon: {
|
||||||
foregroundImage: "./assets/images/logo-icon-small.png",
|
foregroundImage: "./assets/images/logo-icon-small.png",
|
||||||
backgroundColor: "#ffffff"
|
backgroundColor: "#ffffff"
|
||||||
@@ -85,6 +85,8 @@ export default {
|
|||||||
FIREBASE_STORAGE_BUCKET: process.env.FIREBASE_STORAGE_BUCKET,
|
FIREBASE_STORAGE_BUCKET: process.env.FIREBASE_STORAGE_BUCKET,
|
||||||
FIREBASE_MESSAGING_SENDER_ID: process.env.FIREBASE_MESSAGING_SENDER_ID,
|
FIREBASE_MESSAGING_SENDER_ID: process.env.FIREBASE_MESSAGING_SENDER_ID,
|
||||||
FIREBASE_APP_ID: process.env.FIREBASE_APP_ID,
|
FIREBASE_APP_ID: process.env.FIREBASE_APP_ID,
|
||||||
|
URL_MONITORING: process.env.URL_MONITORING,
|
||||||
|
KEY_API_MONITORING: process.env.KEY_API_MONITORING,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -106,9 +106,9 @@ export default function RootLayout() {
|
|||||||
|
|
||||||
async function handleReadNotification(id: string, category: string, idContent: string, title: string) {
|
async function handleReadNotification(id: string, category: string, idContent: string, title: string) {
|
||||||
try {
|
try {
|
||||||
if (title != "Komentar Baru") {
|
if (title !== "Komentar Baru") {
|
||||||
const hasil = await decryptToken(String(token?.current))
|
const hasil = await decryptToken(String(token?.current))
|
||||||
const response = await apiReadOneNotification({ user: hasil, id: id })
|
await apiReadOneNotification({ user: hasil, id: id })
|
||||||
}
|
}
|
||||||
pushToPage(category, idContent)
|
pushToPage(category, idContent)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -203,8 +203,6 @@ export default function RootLayout() {
|
|||||||
<Stack.Screen name="feature" options={{ title: 'Fitur' }} />
|
<Stack.Screen name="feature" options={{ title: 'Fitur' }} />
|
||||||
<Stack.Screen name="search" options={{ title: 'Pencarian' }} />
|
<Stack.Screen name="search" options={{ title: 'Pencarian' }} />
|
||||||
<Stack.Screen name="notification" options={{
|
<Stack.Screen name="notification" options={{
|
||||||
title: 'Notifikasi',
|
|
||||||
// headerLeft: () => <ButtonBackHeader onPress={() => { router.back() }} />,
|
|
||||||
headerTitle: 'Notifikasi',
|
headerTitle: 'Notifikasi',
|
||||||
headerTitleAlign: 'center',
|
headerTitleAlign: 'center',
|
||||||
header: () => (
|
header: () => (
|
||||||
@@ -213,10 +211,8 @@ export default function RootLayout() {
|
|||||||
}} />
|
}} />
|
||||||
<Stack.Screen name="profile" options={{ title: 'Profile' }} />
|
<Stack.Screen name="profile" options={{ title: 'Profile' }} />
|
||||||
<Stack.Screen name="setting/index" options={{
|
<Stack.Screen name="setting/index" options={{
|
||||||
// headerLeft: () => <ButtonBackHeader onPress={() => { router.back() }} />,
|
|
||||||
title: 'Pengaturan',
|
title: 'Pengaturan',
|
||||||
headerTitleAlign: 'center',
|
headerTitleAlign: 'center',
|
||||||
// headerRight: () => <HeaderRightProjectList />
|
|
||||||
header: () => (
|
header: () => (
|
||||||
<AppHeader title="Pengaturan"
|
<AppHeader title="Pengaturan"
|
||||||
showBack={true}
|
showBack={true}
|
||||||
@@ -225,10 +221,8 @@ export default function RootLayout() {
|
|||||||
)
|
)
|
||||||
}} />
|
}} />
|
||||||
<Stack.Screen name="member/index" options={{
|
<Stack.Screen name="member/index" options={{
|
||||||
// headerLeft: () => <ButtonBackHeader onPress={() => { router.back() }} />,
|
|
||||||
title: 'Anggota',
|
title: 'Anggota',
|
||||||
headerTitleAlign: 'center',
|
headerTitleAlign: 'center',
|
||||||
// headerRight: () => <HeaderMemberList />
|
|
||||||
header: () => (
|
header: () => (
|
||||||
<AppHeader title="Anggota"
|
<AppHeader title="Anggota"
|
||||||
showBack={true}
|
showBack={true}
|
||||||
@@ -238,10 +232,8 @@ export default function RootLayout() {
|
|||||||
)
|
)
|
||||||
}} />
|
}} />
|
||||||
<Stack.Screen name="discussion/index" options={{
|
<Stack.Screen name="discussion/index" options={{
|
||||||
// headerLeft: () => <ButtonBackHeader onPress={() => { router.back() }} />,
|
|
||||||
title: 'Diskusi Umum',
|
title: 'Diskusi Umum',
|
||||||
headerTitleAlign: 'center',
|
headerTitleAlign: 'center',
|
||||||
// headerRight: () => <HeaderDiscussionGeneral />
|
|
||||||
header: () => (
|
header: () => (
|
||||||
<AppHeader
|
<AppHeader
|
||||||
title="Diskusi Umum"
|
title="Diskusi Umum"
|
||||||
@@ -252,10 +244,8 @@ export default function RootLayout() {
|
|||||||
)
|
)
|
||||||
}} />
|
}} />
|
||||||
<Stack.Screen name="project/index" options={{
|
<Stack.Screen name="project/index" options={{
|
||||||
// headerLeft: () => <ButtonBackHeader onPress={() => { router.back() }} />,
|
|
||||||
title: 'Kegiatan',
|
title: 'Kegiatan',
|
||||||
headerTitleAlign: 'center',
|
headerTitleAlign: 'center',
|
||||||
// headerRight: () => <HeaderRightProjectList />
|
|
||||||
header: () => (
|
header: () => (
|
||||||
<AppHeader title="Kegiatan"
|
<AppHeader title="Kegiatan"
|
||||||
showBack={true}
|
showBack={true}
|
||||||
@@ -265,10 +255,8 @@ export default function RootLayout() {
|
|||||||
)
|
)
|
||||||
}} />
|
}} />
|
||||||
<Stack.Screen name="division/index" options={{
|
<Stack.Screen name="division/index" options={{
|
||||||
// headerLeft: () => <ButtonBackHeader onPress={() => { router.back() }} />,
|
|
||||||
title: 'Divisi',
|
title: 'Divisi',
|
||||||
headerTitleAlign: 'center',
|
headerTitleAlign: 'center',
|
||||||
// headerRight: () => <HeaderRightDivisionList />
|
|
||||||
header: () => (
|
header: () => (
|
||||||
<AppHeader title="Divisi"
|
<AppHeader title="Divisi"
|
||||||
showBack={true}
|
showBack={true}
|
||||||
@@ -279,10 +267,8 @@ export default function RootLayout() {
|
|||||||
}} />
|
}} />
|
||||||
<Stack.Screen name="division/[id]/(fitur-division)" options={{ headerShown: false }} />
|
<Stack.Screen name="division/[id]/(fitur-division)" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="group/index" options={{
|
<Stack.Screen name="group/index" options={{
|
||||||
// headerLeft: () => <ButtonBackHeader onPress={() => { router.back() }} />,
|
|
||||||
headerTitle: 'Lembaga Desa',
|
headerTitle: 'Lembaga Desa',
|
||||||
headerTitleAlign: 'center',
|
headerTitleAlign: 'center',
|
||||||
// headerRight: () => <HeaderRightGroupList />
|
|
||||||
header: () => (
|
header: () => (
|
||||||
<AppHeader title="Lembaga Desa"
|
<AppHeader title="Lembaga Desa"
|
||||||
showBack={true}
|
showBack={true}
|
||||||
@@ -292,10 +278,8 @@ export default function RootLayout() {
|
|||||||
)
|
)
|
||||||
}} />
|
}} />
|
||||||
<Stack.Screen name="position/index" options={{
|
<Stack.Screen name="position/index" options={{
|
||||||
// headerLeft: () => <ButtonBackHeader onPress={() => { router.back() }} />,
|
|
||||||
headerTitle: 'Jabatan',
|
headerTitle: 'Jabatan',
|
||||||
headerTitleAlign: 'center',
|
headerTitleAlign: 'center',
|
||||||
// headerRight: () => <HeaderRightPositionList />
|
|
||||||
header: () => (
|
header: () => (
|
||||||
<AppHeader title="Jabatan"
|
<AppHeader title="Jabatan"
|
||||||
showBack={true}
|
showBack={true}
|
||||||
@@ -306,10 +290,8 @@ export default function RootLayout() {
|
|||||||
}} />
|
}} />
|
||||||
<Stack.Screen name="announcement/index"
|
<Stack.Screen name="announcement/index"
|
||||||
options={{
|
options={{
|
||||||
// headerLeft: () => <ButtonBackHeader onPress={() => { router.back() }} />,
|
|
||||||
headerTitle: 'Pengumuman',
|
headerTitle: 'Pengumuman',
|
||||||
headerTitleAlign: 'center',
|
headerTitleAlign: 'center',
|
||||||
// headerRight: () => <HeaderRightAnnouncementList />
|
|
||||||
header: () => (
|
header: () => (
|
||||||
<AppHeader title="Pengumuman"
|
<AppHeader title="Pengumuman"
|
||||||
showBack={true}
|
showBack={true}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import AppHeader from "@/components/AppHeader";
|
|||||||
import BorderBottomItem from "@/components/borderBottomItem";
|
import BorderBottomItem from "@/components/borderBottomItem";
|
||||||
import Skeleton from "@/components/skeleton";
|
import Skeleton from "@/components/skeleton";
|
||||||
import Text from '@/components/Text';
|
import Text from '@/components/Text';
|
||||||
|
import ErrorView from "@/components/ErrorView";
|
||||||
import { ConstEnv } from "@/constants/ConstEnv";
|
import { ConstEnv } from "@/constants/ConstEnv";
|
||||||
import { isImageFile } from "@/constants/FileExtensions";
|
import { isImageFile } from "@/constants/FileExtensions";
|
||||||
import Styles from "@/constants/Styles";
|
import Styles from "@/constants/Styles";
|
||||||
@@ -65,6 +66,7 @@ export default function DetailAnnouncement() {
|
|||||||
const [loadingOpen, setLoadingOpen] = useState(false)
|
const [loadingOpen, setLoadingOpen] = useState(false)
|
||||||
const [preview, setPreview] = useState(false)
|
const [preview, setPreview] = useState(false)
|
||||||
const [chooseFile, setChooseFile] = useState<FileData>()
|
const [chooseFile, setChooseFile] = useState<FileData>()
|
||||||
|
const [isError, setIsError] = useState(false)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens the image preview modal for the selected image file
|
* Opens the image preview modal for the selected image file
|
||||||
@@ -79,6 +81,7 @@ export default function DetailAnnouncement() {
|
|||||||
|
|
||||||
async function handleLoad(loading: boolean) {
|
async function handleLoad(loading: boolean) {
|
||||||
try {
|
try {
|
||||||
|
setIsError(false)
|
||||||
setLoading(loading)
|
setLoading(loading)
|
||||||
const hasil = await decryptToken(String(token?.current))
|
const hasil = await decryptToken(String(token?.current))
|
||||||
const response: ApiResponse = await apiGetAnnouncementOne({ id: id, user: hasil })
|
const response: ApiResponse = await apiGetAnnouncementOne({ id: id, user: hasil })
|
||||||
@@ -87,10 +90,12 @@ export default function DetailAnnouncement() {
|
|||||||
setDataMember(response.member)
|
setDataMember(response.member)
|
||||||
setDataFile(response.file)
|
setDataFile(response.file)
|
||||||
} else {
|
} else {
|
||||||
|
setIsError(true)
|
||||||
Toast.show({ type: 'small', text1: response.message })
|
Toast.show({ type: 'small', text1: response.message })
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
setIsError(true)
|
||||||
const message = error?.response?.data?.message || "Gagal mengambil data"
|
const message = error?.response?.data?.message || "Gagal mengambil data"
|
||||||
|
|
||||||
Toast.show({ type: 'small', text1: message })
|
Toast.show({ type: 'small', text1: message })
|
||||||
@@ -206,104 +211,110 @@ export default function DetailAnnouncement() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View style={[Styles.p15, Styles.mb50]}>
|
{isError && !loading ? (
|
||||||
<View style={[Styles.wrapPaper, Styles.borderAll, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
|
<View style={[Styles.mv50]}>
|
||||||
{
|
<ErrorView />
|
||||||
loading ?
|
|
||||||
<View>
|
|
||||||
<View style={[Styles.rowOnly]}>
|
|
||||||
<Skeleton width={30} height={30} borderRadius={10} />
|
|
||||||
<View style={[Styles.flex1, Styles.ph05]}>
|
|
||||||
<Skeleton width={100} widthType="percent" height={30} borderRadius={10} />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<Skeleton width={100} widthType="percent" height={10} borderRadius={10} />
|
|
||||||
<Skeleton width={100} widthType="percent" height={10} borderRadius={10} />
|
|
||||||
<Skeleton width={100} widthType="percent" height={10} borderRadius={10} />
|
|
||||||
</View>
|
|
||||||
:
|
|
||||||
<>
|
|
||||||
<View style={[Styles.rowOnly, Styles.alignStart]}>
|
|
||||||
<MaterialIcons name="campaign" size={25} color={colors.text} style={[Styles.mr05]} />
|
|
||||||
<Text style={[Styles.textDefaultSemiBold, Styles.w90, Styles.mt02]}>{data?.title}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={[Styles.mt10]}>
|
|
||||||
{
|
|
||||||
hasHtmlTags(data?.desc) ?
|
|
||||||
<RenderHTML
|
|
||||||
contentWidth={contentWidth}
|
|
||||||
source={{ html: data?.desc }}
|
|
||||||
baseStyle={{ color: colors.text }}
|
|
||||||
/>
|
|
||||||
:
|
|
||||||
<Text>{data?.desc}</Text>
|
|
||||||
}
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
|
|
||||||
</View>
|
</View>
|
||||||
{
|
) : (
|
||||||
dataFile.length > 0 && (
|
<View style={[Styles.p15, Styles.mb50]}>
|
||||||
<View style={[Styles.wrapPaper, Styles.borderAll, Styles.mt10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
|
<View style={[Styles.wrapPaper, Styles.borderAll, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
|
||||||
<View style={[Styles.mb05]}>
|
{
|
||||||
<Text style={[Styles.textDefaultSemiBold]}>File</Text>
|
loading ?
|
||||||
</View>
|
<View>
|
||||||
{dataFile.map((item, index) => (
|
<View style={[Styles.rowOnly]}>
|
||||||
<BorderBottomItem
|
<Skeleton width={30} height={30} borderRadius={10} />
|
||||||
key={`${item.id}-${index}`}
|
<View style={[Styles.flex1, Styles.ph05]}>
|
||||||
borderType={index === dataFile.length - 1 ? 'none' : 'bottom'}
|
<Skeleton width={100} widthType="percent" height={30} borderRadius={10} />
|
||||||
icon={<MaterialCommunityIcons
|
</View>
|
||||||
name={isImageFile(item.extension) ? "file-image-outline" : "file-document-outline"}
|
|
||||||
size={25}
|
|
||||||
color={colors.text}
|
|
||||||
/>}
|
|
||||||
title={item.name + '.' + item.extension}
|
|
||||||
titleWeight="normal"
|
|
||||||
onPress={() => {
|
|
||||||
isImageFile(item.extension) ?
|
|
||||||
handleChooseFile(item)
|
|
||||||
: openFile(item)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
<View style={[Styles.wrapPaper, Styles.borderAll, Styles.mt10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
|
|
||||||
{
|
|
||||||
loading ?
|
|
||||||
arrSkeleton.map((item, index) => {
|
|
||||||
return (
|
|
||||||
<View key={index}>
|
|
||||||
<Skeleton width={30} widthType="percent" height={10} borderRadius={10} />
|
|
||||||
<Skeleton width={100} widthType="percent" height={10} borderRadius={10} />
|
|
||||||
<Skeleton width={100} widthType="percent" height={10} borderRadius={10} />
|
|
||||||
</View>
|
</View>
|
||||||
)
|
<Skeleton width={100} widthType="percent" height={10} borderRadius={10} />
|
||||||
})
|
<Skeleton width={100} widthType="percent" height={10} borderRadius={10} />
|
||||||
:
|
<Skeleton width={100} widthType="percent" height={10} borderRadius={10} />
|
||||||
Object.keys(dataMember).map((v: any, i: any) => {
|
</View>
|
||||||
return (
|
:
|
||||||
<View key={i} style={[Styles.mb05]}>
|
<>
|
||||||
<Text style={[Styles.textDefaultSemiBold]}>{dataMember[v]?.[0].group}</Text>
|
<View style={[Styles.rowOnly, Styles.alignStart]}>
|
||||||
|
<MaterialIcons name="campaign" size={25} color={colors.text} style={[Styles.mr05]} />
|
||||||
|
<Text style={[Styles.textDefaultSemiBold, Styles.w90, Styles.mt02]}>{data?.title}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[Styles.mt10]}>
|
||||||
{
|
{
|
||||||
dataMember[v].map((item: any, x: any) => {
|
hasHtmlTags(data?.desc) ?
|
||||||
return (
|
<RenderHTML
|
||||||
<View key={x} style={[Styles.rowItemsCenter, Styles.w90]}>
|
contentWidth={contentWidth}
|
||||||
<Entypo name="dot-single" size={24} color={colors.text} />
|
source={{ html: data?.desc }}
|
||||||
<Text style={[Styles.textDefault]} numberOfLines={1} ellipsizeMode='tail'>{item.division}</Text>
|
baseStyle={{ color: colors.text }}
|
||||||
</View>
|
/>
|
||||||
)
|
:
|
||||||
})
|
<Text>{data?.desc}</Text>
|
||||||
}
|
}
|
||||||
|
|
||||||
</View>
|
</View>
|
||||||
)
|
</>
|
||||||
})
|
}
|
||||||
|
|
||||||
|
</View>
|
||||||
|
{
|
||||||
|
dataFile.length > 0 && (
|
||||||
|
<View style={[Styles.wrapPaper, Styles.borderAll, Styles.mt10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
|
||||||
|
<View style={[Styles.mb05]}>
|
||||||
|
<Text style={[Styles.textDefaultSemiBold]}>File</Text>
|
||||||
|
</View>
|
||||||
|
{dataFile.map((item, index) => (
|
||||||
|
<BorderBottomItem
|
||||||
|
key={`${item.id}-${index}`}
|
||||||
|
borderType={index === dataFile.length - 1 ? 'none' : 'bottom'}
|
||||||
|
icon={<MaterialCommunityIcons
|
||||||
|
name={isImageFile(item.extension) ? "file-image-outline" : "file-document-outline"}
|
||||||
|
size={25}
|
||||||
|
color={colors.text}
|
||||||
|
/>}
|
||||||
|
title={item.name + '.' + item.extension}
|
||||||
|
titleWeight="normal"
|
||||||
|
onPress={() => {
|
||||||
|
isImageFile(item.extension) ?
|
||||||
|
handleChooseFile(item)
|
||||||
|
: openFile(item)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
<View style={[Styles.wrapPaper, Styles.borderAll, Styles.mt10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
|
||||||
|
{
|
||||||
|
loading ?
|
||||||
|
arrSkeleton.map((item, index) => {
|
||||||
|
return (
|
||||||
|
<View key={index}>
|
||||||
|
<Skeleton width={30} widthType="percent" height={10} borderRadius={10} />
|
||||||
|
<Skeleton width={100} widthType="percent" height={10} borderRadius={10} />
|
||||||
|
<Skeleton width={100} widthType="percent" height={10} borderRadius={10} />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
:
|
||||||
|
Object.keys(dataMember).map((v: any, i: any) => {
|
||||||
|
return (
|
||||||
|
<View key={i} style={[Styles.mb05]}>
|
||||||
|
<Text style={[Styles.textDefaultSemiBold]}>{dataMember[v]?.[0].group}</Text>
|
||||||
|
{
|
||||||
|
dataMember[v].map((item: any, x: any) => {
|
||||||
|
return (
|
||||||
|
<View key={x} style={[Styles.rowItemsCenter, Styles.w90]}>
|
||||||
|
<Entypo name="dot-single" size={24} color={colors.text} />
|
||||||
|
<Text style={[Styles.textDefault]} numberOfLines={1} ellipsizeMode='tail'>{item.division}</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
<ImageViewing
|
<ImageViewing
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ import BorderBottomItem from "@/components/borderBottomItem";
|
|||||||
import InputSearch from "@/components/inputSearch";
|
import InputSearch from "@/components/inputSearch";
|
||||||
import SkeletonContent from "@/components/skeletonContent";
|
import SkeletonContent from "@/components/skeletonContent";
|
||||||
import Text from '@/components/Text';
|
import Text from '@/components/Text';
|
||||||
import { ColorsStatus } from "@/constants/ColorsStatus";
|
|
||||||
import Styles from "@/constants/Styles";
|
import Styles from "@/constants/Styles";
|
||||||
import { apiGetAnnouncement } from "@/lib/api";
|
import { apiGetAnnouncement } from "@/lib/api";
|
||||||
import { useAuthSession } from "@/providers/AuthProvider";
|
import { useAuthSession } from "@/providers/AuthProvider";
|
||||||
import { useTheme } from "@/providers/ThemeProvider";
|
import { useTheme } from "@/providers/ThemeProvider";
|
||||||
import { MaterialIcons } from "@expo/vector-icons";
|
import { MaterialIcons } from "@expo/vector-icons";
|
||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { RefreshControl, View, VirtualizedList } from "react-native";
|
import { RefreshControl, View, VirtualizedList } from "react-native";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
|
|
||||||
@@ -20,68 +20,60 @@ type Props = {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export default function Announcement() {
|
export default function Announcement() {
|
||||||
const { token, decryptToken } = useAuthSession()
|
const { token, decryptToken } = useAuthSession()
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const [data, setData] = useState<Props[]>([])
|
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const update = useSelector((state: any) => state.announcementUpdate)
|
const update = useSelector((state: any) => state.announcementUpdate)
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const arrSkeleton = Array.from({ length: 5 }, (_, index) => index)
|
const arrSkeleton = Array.from({ length: 5 }, (_, index) => index)
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [waiting, setWaiting] = useState(false)
|
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
|
||||||
|
|
||||||
async function handleLoad(loading: boolean, thisPage: number) {
|
// TanStack Query Infinite Query
|
||||||
try {
|
const {
|
||||||
setWaiting(true)
|
data,
|
||||||
setLoading(loading)
|
fetchNextPage,
|
||||||
setPage(thisPage)
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
isLoading,
|
||||||
|
refetch,
|
||||||
|
isRefetching
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ['announcements', search],
|
||||||
|
queryFn: async ({ pageParam = 1 }) => {
|
||||||
const hasil = await decryptToken(String(token?.current))
|
const hasil = await decryptToken(String(token?.current))
|
||||||
const response = await apiGetAnnouncement({ user: hasil, search: search, page: thisPage })
|
const response = await apiGetAnnouncement({
|
||||||
if (thisPage == 1) {
|
user: hasil,
|
||||||
setData(response.data)
|
search: search,
|
||||||
} else if (thisPage > 1 && response.data.length > 0) {
|
page: pageParam
|
||||||
setData([...data, ...response.data])
|
})
|
||||||
} else {
|
return response.data
|
||||||
return;
|
},
|
||||||
}
|
initialPageParam: 1,
|
||||||
} catch (error) {
|
getNextPageParam: (lastPage, allPages) => {
|
||||||
console.error(error)
|
return lastPage.length > 0 ? allPages.length + 1 : undefined
|
||||||
} finally {
|
},
|
||||||
setLoading(false)
|
})
|
||||||
setWaiting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Trigger refetch when Redux state 'update' changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handleLoad(false, 1)
|
refetch()
|
||||||
}, [update])
|
}, [update, refetch])
|
||||||
|
|
||||||
useEffect(() => {
|
// Flatten data from pages
|
||||||
handleLoad(true, 1)
|
const flattenedData = useMemo(() => {
|
||||||
}, [search])
|
return data?.pages.flat() || []
|
||||||
|
}, [data])
|
||||||
|
|
||||||
const loadMoreData = () => {
|
const loadMoreData = () => {
|
||||||
if (waiting) return
|
if (hasNextPage && !isFetchingNextPage) {
|
||||||
setTimeout(() => {
|
fetchNextPage()
|
||||||
handleLoad(false, page + 1)
|
}
|
||||||
}, 1000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
|
||||||
setRefreshing(true)
|
|
||||||
handleLoad(false, 1)
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
||||||
setRefreshing(false)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getItem = (_data: unknown, index: number): Props => ({
|
const getItem = (_data: unknown, index: number): Props => ({
|
||||||
id: data[index].id,
|
id: flattenedData[index].id,
|
||||||
title: data[index].title,
|
title: flattenedData[index].title,
|
||||||
desc: data[index].desc,
|
desc: flattenedData[index].desc,
|
||||||
createdAt: data[index].createdAt,
|
createdAt: flattenedData[index].createdAt,
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -91,18 +83,18 @@ export default function Announcement() {
|
|||||||
</View>
|
</View>
|
||||||
<View style={[Styles.flex2, Styles.mt05]}>
|
<View style={[Styles.flex2, Styles.mt05]}>
|
||||||
{
|
{
|
||||||
loading ?
|
isLoading && !flattenedData.length ?
|
||||||
arrSkeleton.map((item, index) => {
|
arrSkeleton.map((item, index) => {
|
||||||
return (
|
return (
|
||||||
<SkeletonContent key={index} />
|
<SkeletonContent key={index} />
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
:
|
:
|
||||||
data.length > 0
|
flattenedData.length > 0
|
||||||
?
|
?
|
||||||
<VirtualizedList
|
<VirtualizedList
|
||||||
data={data}
|
data={flattenedData}
|
||||||
getItemCount={() => data.length}
|
getItemCount={() => flattenedData.length}
|
||||||
getItem={getItem}
|
getItem={getItem}
|
||||||
renderItem={({ item, index }: { item: Props, index: number }) => {
|
renderItem={({ item, index }: { item: Props, index: number }) => {
|
||||||
return (
|
return (
|
||||||
@@ -112,9 +104,7 @@ export default function Announcement() {
|
|||||||
borderType="bottom"
|
borderType="bottom"
|
||||||
bgColor="transparent"
|
bgColor="transparent"
|
||||||
icon={
|
icon={
|
||||||
// <View style={[Styles.iconContent]}>
|
|
||||||
<MaterialIcons name="campaign" size={25} color={colors.text} />
|
<MaterialIcons name="campaign" size={25} color={colors.text} />
|
||||||
// </View>
|
|
||||||
}
|
}
|
||||||
title={item.title}
|
title={item.title}
|
||||||
desc={item.desc.replace(/<[^>]*>?/gm, '').replace(/\r?\n|\r/g, ' ')}
|
desc={item.desc.replace(/<[^>]*>?/gm, '').replace(/\r?\n|\r/g, ' ')}
|
||||||
@@ -122,14 +112,14 @@ export default function Announcement() {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
keyExtractor={(item, index) => String(index)}
|
keyExtractor={(item, index) => String(item.id || index)}
|
||||||
onEndReached={loadMoreData}
|
onEndReached={loadMoreData}
|
||||||
onEndReachedThreshold={0.5}
|
onEndReachedThreshold={0.5}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
refreshing={refreshing}
|
refreshing={isRefetching && !isFetchingNextPage}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={refetch}
|
||||||
tintColor={colors.icon}
|
tintColor={colors.icon}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import styles from "@/components/AppHeader"
|
||||||
import AppHeader from "@/components/AppHeader"
|
import AppHeader from "@/components/AppHeader"
|
||||||
import HeaderRightBannerList from "@/components/banner/headerBannerList"
|
import HeaderRightBannerList from "@/components/banner/headerBannerList"
|
||||||
import BorderBottomItem from "@/components/borderBottomItem"
|
import BorderBottomItem from "@/components/borderBottomItem"
|
||||||
@@ -5,6 +6,7 @@ import DrawerBottom from "@/components/drawerBottom"
|
|||||||
import MenuItemRow from "@/components/menuItemRow"
|
import MenuItemRow from "@/components/menuItemRow"
|
||||||
import ModalConfirmation from "@/components/ModalConfirmation"
|
import ModalConfirmation from "@/components/ModalConfirmation"
|
||||||
import ModalLoading from "@/components/modalLoading"
|
import ModalLoading from "@/components/modalLoading"
|
||||||
|
import Skeleton from "@/components/skeleton"
|
||||||
import Text from "@/components/Text"
|
import Text from "@/components/Text"
|
||||||
import { ConstEnv } from "@/constants/ConstEnv"
|
import { ConstEnv } from "@/constants/ConstEnv"
|
||||||
import Styles from "@/constants/Styles"
|
import Styles from "@/constants/Styles"
|
||||||
@@ -13,11 +15,12 @@ import { setEntities } from "@/lib/bannerSlice"
|
|||||||
import { useAuthSession } from "@/providers/AuthProvider"
|
import { useAuthSession } from "@/providers/AuthProvider"
|
||||||
import { useTheme } from "@/providers/ThemeProvider"
|
import { useTheme } from "@/providers/ThemeProvider"
|
||||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"
|
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
import * as FileSystem from 'expo-file-system'
|
import * as FileSystem from 'expo-file-system'
|
||||||
import { startActivityAsync } from 'expo-intent-launcher'
|
import { startActivityAsync } from 'expo-intent-launcher'
|
||||||
import { router, Stack } from "expo-router"
|
import { router, Stack } from "expo-router"
|
||||||
import * as Sharing from 'expo-sharing'
|
import * as Sharing from 'expo-sharing'
|
||||||
import { useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { Alert, Image, Platform, RefreshControl, SafeAreaView, ScrollView, View } from "react-native"
|
import { Alert, Image, Platform, RefreshControl, SafeAreaView, ScrollView, View } from "react-native"
|
||||||
import ImageViewing from 'react-native-image-viewing'
|
import ImageViewing from 'react-native-image-viewing'
|
||||||
import * as mime from 'react-native-mime-types'
|
import * as mime from 'react-native-mime-types'
|
||||||
@@ -43,36 +46,51 @@ export default function BannerList() {
|
|||||||
const [loadingOpen, setLoadingOpen] = useState(false)
|
const [loadingOpen, setLoadingOpen] = useState(false)
|
||||||
const [viewImg, setViewImg] = useState(false)
|
const [viewImg, setViewImg] = useState(false)
|
||||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const handleDeleteEntity = async () => {
|
// 1. Fetching logic with useQuery
|
||||||
try {
|
const { data: bannersRes, isLoading } = useQuery({
|
||||||
const hasil = await decryptToken(String(token?.current));
|
queryKey: ['banners'],
|
||||||
const deletedEntity = await apiDeleteBanner({ user: hasil }, dataId);
|
queryFn: async () => {
|
||||||
if (deletedEntity.success) {
|
const hasil = await decryptToken(String(token?.current))
|
||||||
Toast.show({ type: 'small', text1: 'Berhasil menghapus data', })
|
const response = await apiGetBanner({ user: hasil })
|
||||||
apiGetBanner({ user: hasil }).then((data) =>
|
return response.data || []
|
||||||
dispatch(setEntities(data.data))
|
},
|
||||||
);
|
enabled: !!token?.current,
|
||||||
} else {
|
staleTime: 0,
|
||||||
Toast.show({ type: 'small', text1: 'Gagal menghapus data', })
|
})
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(error);
|
|
||||||
const message = error?.response?.data?.message || "Gagal menghapus data"
|
|
||||||
|
|
||||||
Toast.show({ type: 'small', text1: message })
|
// Sync results with Redux
|
||||||
} finally {
|
useEffect(() => {
|
||||||
setModal(false)
|
if (bannersRes) {
|
||||||
|
dispatch(setEntities(bannersRes))
|
||||||
}
|
}
|
||||||
|
}, [bannersRes, dispatch])
|
||||||
|
|
||||||
|
// 2. Deletion logic with useMutation
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
const hasil = await decryptToken(String(token?.current))
|
||||||
|
return await apiDeleteBanner({ user: hasil }, id)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
Toast.show({ type: 'small', text1: 'Berhasil menghapus data' })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['banners'] })
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
const message = error?.response?.data?.message || "Gagal menghapus data"
|
||||||
|
Toast.show({ type: 'small', text1: message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleDeleteEntity = () => {
|
||||||
|
deleteMutation.mutate(dataId)
|
||||||
|
setModal(false)
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
setRefreshing(true)
|
setRefreshing(true)
|
||||||
const hasil = await decryptToken(String(token?.current));
|
await queryClient.invalidateQueries({ queryKey: ['banners'] })
|
||||||
apiGetBanner({ user: hasil }).then((data) =>
|
|
||||||
dispatch(setEntities(data.data))
|
|
||||||
);
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
||||||
setRefreshing(false)
|
setRefreshing(false)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -140,36 +158,40 @@ export default function BannerList() {
|
|||||||
}
|
}
|
||||||
style={[Styles.h100, { backgroundColor: colors.background }]}
|
style={[Styles.h100, { backgroundColor: colors.background }]}
|
||||||
>
|
>
|
||||||
{
|
<View style={[Styles.p15, Styles.mb100]}>
|
||||||
entities.length > 0
|
{
|
||||||
?
|
isLoading ? (
|
||||||
<View style={[Styles.p15, Styles.mb100]}>
|
<>
|
||||||
{entities.map((index: any, key: number) => (
|
<Skeleton width={100} height={150} borderRadius={10} widthType="percent" />
|
||||||
<BorderBottomItem
|
<Skeleton width={100} height={150} borderRadius={10} widthType="percent" />
|
||||||
key={key}
|
<Skeleton width={100} height={150} borderRadius={10} widthType="percent" />
|
||||||
onPress={() => {
|
</>
|
||||||
setDataId(index.id)
|
) :
|
||||||
setSelectFile(index)
|
entities.length > 0 ?
|
||||||
setModal(true)
|
entities.map((index: any, key: number) => (
|
||||||
}}
|
<BorderBottomItem
|
||||||
borderType="all"
|
key={key}
|
||||||
icon={
|
onPress={() => {
|
||||||
<Image
|
setDataId(index.id)
|
||||||
source={{ uri: `${ConstEnv.url_storage}/files/${index.image}` }}
|
setSelectFile(index)
|
||||||
style={[Styles.imgListBanner]}
|
setModal(true)
|
||||||
/>
|
}}
|
||||||
}
|
borderType="all"
|
||||||
title={index.title}
|
icon={
|
||||||
/>
|
<Image
|
||||||
))}
|
source={{ uri: `${ConstEnv.url_storage}/files/${index.image}` }}
|
||||||
</View>
|
style={[Styles.imgListBanner]}
|
||||||
:
|
/>
|
||||||
<View style={[Styles.p15, Styles.mb100]}>
|
}
|
||||||
<Text style={[Styles.textDefault, Styles.textCenter]}>Tidak ada data</Text>
|
title={index.title}
|
||||||
</View>
|
/>
|
||||||
}
|
))
|
||||||
|
:
|
||||||
|
<View style={[Styles.p15, Styles.mb100]}>
|
||||||
|
<Text style={[Styles.textDefault, Styles.textCenter]}>Tidak ada data</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
<DrawerBottom animation="slide" isVisible={isModal} setVisible={() => setModal(false)} title="Menu">
|
<DrawerBottom animation="slide" isVisible={isModal} setVisible={() => setModal(false)} title="Menu">
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ import { apiGetDiscussionGeneral } from "@/lib/api";
|
|||||||
import { useAuthSession } from "@/providers/AuthProvider";
|
import { useAuthSession } from "@/providers/AuthProvider";
|
||||||
import { useTheme } from "@/providers/ThemeProvider";
|
import { useTheme } from "@/providers/ThemeProvider";
|
||||||
import { AntDesign, Feather, Ionicons, MaterialIcons } from "@expo/vector-icons";
|
import { AntDesign, Feather, Ionicons, MaterialIcons } from "@expo/vector-icons";
|
||||||
|
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { router, useLocalSearchParams } from "expo-router";
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { RefreshControl, View, VirtualizedList } from "react-native";
|
import { RefreshControl, View, VirtualizedList } from "react-native";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
|
|
||||||
@@ -32,70 +33,76 @@ export default function Discussion() {
|
|||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const { active, group } = useLocalSearchParams<{ active?: string, group?: string }>()
|
const { active, group } = useLocalSearchParams<{ active?: string, group?: string }>()
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [nameGroup, setNameGroup] = useState('')
|
|
||||||
const [data, setData] = useState<Props[]>([])
|
|
||||||
const update = useSelector((state: any) => state.discussionGeneralDetailUpdate)
|
const update = useSelector((state: any) => state.discussionGeneralDetailUpdate)
|
||||||
const [loading, setLoading] = useState(true)
|
const queryClient = useQueryClient()
|
||||||
const arrSkeleton = Array.from({ length: 5 }, (_, index) => index)
|
const [status, setStatus] = useState<'true' | 'false'>(active == 'false' ? 'false' : 'true')
|
||||||
const [status, setStatus] = useState<'true' | 'false'>('true')
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [waiting, setWaiting] = useState(false)
|
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
|
||||||
async function handleLoad(loading: boolean, thisPage: number) {
|
// TanStack Query for Discussions with Infinite Scroll
|
||||||
try {
|
const {
|
||||||
setWaiting(true)
|
data,
|
||||||
setLoading(loading)
|
fetchNextPage,
|
||||||
setPage(thisPage)
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
isLoading,
|
||||||
|
refetch
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ['discussions', { status, search, group }],
|
||||||
|
queryFn: async ({ pageParam = 1 }) => {
|
||||||
const hasil = await decryptToken(String(token?.current))
|
const hasil = await decryptToken(String(token?.current))
|
||||||
const response = await apiGetDiscussionGeneral({ user: hasil, active: status, search: search, group: String(group), page: thisPage })
|
const response = await apiGetDiscussionGeneral({
|
||||||
if (thisPage == 1) {
|
user: hasil,
|
||||||
setData(response.data)
|
active: status,
|
||||||
} else if (thisPage > 1 && response.data.length > 0) {
|
search: search,
|
||||||
setData([...data, ...response.data])
|
group: String(group),
|
||||||
} else {
|
page: pageParam
|
||||||
return;
|
})
|
||||||
}
|
return response;
|
||||||
setNameGroup(response.filter.name)
|
},
|
||||||
} catch (error) {
|
initialPageParam: 1,
|
||||||
console.error(error)
|
getNextPageParam: (lastPage, allPages) => {
|
||||||
} finally {
|
return lastPage.data.length > 0 ? allPages.length + 1 : undefined;
|
||||||
setLoading(false)
|
},
|
||||||
setWaiting(false)
|
enabled: !!token?.current,
|
||||||
}
|
staleTime: 0,
|
||||||
}
|
})
|
||||||
|
|
||||||
|
// Flatten pages into a single data array
|
||||||
|
const flatData = useMemo(() => {
|
||||||
|
return data?.pages.flatMap(page => page.data) || [];
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
// Get nameGroup from the first available page
|
||||||
|
const nameGroup = useMemo(() => {
|
||||||
|
return data?.pages[0]?.filter?.name || "";
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
// Refetch when manual update state changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handleLoad(false, 1)
|
refetch()
|
||||||
}, [update])
|
}, [update, refetch])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
handleLoad(true, 1)
|
|
||||||
}, [status, search, group])
|
|
||||||
|
|
||||||
|
|
||||||
const loadMoreData = () => {
|
|
||||||
if (waiting) return
|
|
||||||
setTimeout(() => {
|
|
||||||
handleLoad(false, page + 1)
|
|
||||||
}, 1000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
setRefreshing(true)
|
setRefreshing(true)
|
||||||
handleLoad(false, 1)
|
await queryClient.invalidateQueries({ queryKey: ['discussions'] })
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
||||||
setRefreshing(false)
|
setRefreshing(false)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadMoreData = () => {
|
||||||
|
if (hasNextPage && !isFetchingNextPage) {
|
||||||
|
fetchNextPage()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const arrSkeleton = [0, 1, 2, 3, 4]
|
||||||
|
|
||||||
const getItem = (_data: unknown, index: number): Props => ({
|
const getItem = (_data: unknown, index: number): Props => ({
|
||||||
id: data[index].id,
|
id: flatData[index]?.id,
|
||||||
title: data[index].title,
|
title: flatData[index]?.title,
|
||||||
desc: data[index].desc,
|
desc: flatData[index]?.desc,
|
||||||
status: data[index].status,
|
status: flatData[index]?.status,
|
||||||
total_komentar: data[index].total_komentar,
|
total_komentar: flatData[index]?.total_komentar,
|
||||||
createdAt: data[index].createdAt,
|
createdAt: flatData[index]?.createdAt,
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -132,18 +139,18 @@ export default function Discussion() {
|
|||||||
</View>
|
</View>
|
||||||
<View style={[Styles.flex2, Styles.mt05]}>
|
<View style={[Styles.flex2, Styles.mt05]}>
|
||||||
{
|
{
|
||||||
loading ?
|
isLoading ?
|
||||||
arrSkeleton.map((item: any, i: number) => {
|
arrSkeleton.map((item: any, i: number) => {
|
||||||
return (
|
return (
|
||||||
<SkeletonContent key={i} />
|
<SkeletonContent key={i} />
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
:
|
:
|
||||||
data.length > 0
|
flatData.length > 0
|
||||||
?
|
?
|
||||||
<VirtualizedList
|
<VirtualizedList
|
||||||
data={data}
|
data={flatData}
|
||||||
getItemCount={() => data.length}
|
getItemCount={() => flatData.length}
|
||||||
getItem={getItem}
|
getItem={getItem}
|
||||||
renderItem={({ item, index }: { item: Props, index: number }) => {
|
renderItem={({ item, index }: { item: Props, index: number }) => {
|
||||||
return (
|
return (
|
||||||
@@ -153,16 +160,14 @@ export default function Discussion() {
|
|||||||
onPress={() => { router.push(`/discussion/${item.id}`) }}
|
onPress={() => { router.push(`/discussion/${item.id}`) }}
|
||||||
borderType="bottom"
|
borderType="bottom"
|
||||||
icon={
|
icon={
|
||||||
// <View style={[Styles.iconContent]}>
|
|
||||||
<MaterialIcons name="chat" size={25} color={colors.text} />
|
<MaterialIcons name="chat" size={25} color={colors.text} />
|
||||||
// </View>
|
|
||||||
}
|
}
|
||||||
title={item.title}
|
title={item.title}
|
||||||
subtitle={
|
subtitle={
|
||||||
status != "false" && <LabelStatus category={item.status === 1 ? "success" : "error"} text={item.status === 1 ? "BUKA" : "TUTUP"} size="small" />
|
status != "false" && <LabelStatus category={item.status === 1 ? "success" : "error"} text={item.status === 1 ? "BUKA" : "TUTUP"} size="small" />
|
||||||
}
|
}
|
||||||
rightTopInfo={item.createdAt}
|
rightTopInfo={item.createdAt}
|
||||||
desc={item.desc.replace(/<[^>]*>?/gm, ' ').replace(/\r?\n|\r/g, ' ')}
|
desc={item.desc?.replace(/<[^>]*>?/gm, ' ').replace(/\r?\n|\r/g, ' ')}
|
||||||
leftBottomInfo={
|
leftBottomInfo={
|
||||||
<View style={[Styles.rowItemsCenter]}>
|
<View style={[Styles.rowItemsCenter]}>
|
||||||
<Ionicons name="chatbox-ellipses-outline" size={18} color={colors.dimmed} style={Styles.mr05} />
|
<Ionicons name="chatbox-ellipses-outline" size={18} color={colors.dimmed} style={Styles.mr05} />
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ import {
|
|||||||
Ionicons,
|
Ionicons,
|
||||||
MaterialCommunityIcons
|
MaterialCommunityIcons
|
||||||
} from "@expo/vector-icons";
|
} from "@expo/vector-icons";
|
||||||
|
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { router, useLocalSearchParams } from "expo-router";
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Pressable, RefreshControl, View, VirtualizedList } from "react-native";
|
import { Pressable, RefreshControl, View, VirtualizedList } from "react-native";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
|
|
||||||
@@ -40,23 +41,23 @@ export default function ListDivision() {
|
|||||||
const { token, decryptToken } = useAuthSession()
|
const { token, decryptToken } = useAuthSession()
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [nameGroup, setNameGroup] = useState("")
|
const queryClient = useQueryClient()
|
||||||
const [data, setData] = useState<Props[]>([])
|
const [status, setStatus] = useState<'true' | 'false'>(active == 'false' ? 'false' : 'true')
|
||||||
// ... state same ...
|
const [category, setCategory] = useState<'divisi-saya' | 'semua'>(cat == 'semua' ? 'semua' : 'divisi-saya')
|
||||||
const update = useSelector((state: any) => state.divisionUpdate)
|
const update = useSelector((state: any) => state.divisionUpdate)
|
||||||
const arrSkeleton = Array.from({ length: 3 }, (_, index) => index)
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [status, setStatus] = useState<'true' | 'false'>('true')
|
|
||||||
const [category, setCategory] = useState<'divisi-saya' | 'semua'>('divisi-saya')
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [waiting, setWaiting] = useState(false)
|
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
|
||||||
async function handleLoad(loading: boolean, thisPage: number) {
|
// TanStack Query for Divisions with Infinite Scroll
|
||||||
try {
|
const {
|
||||||
setWaiting(true)
|
data,
|
||||||
setLoading(loading)
|
fetchNextPage,
|
||||||
setPage(thisPage)
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
isLoading,
|
||||||
|
refetch
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ['divisions', { status, search, group, category }],
|
||||||
|
queryFn: async ({ pageParam = 1 }) => {
|
||||||
const hasil = await decryptToken(String(token?.current));
|
const hasil = await decryptToken(String(token?.current));
|
||||||
const response = await apiGetDivision({
|
const response = await apiGetDivision({
|
||||||
user: hasil,
|
user: hasil,
|
||||||
@@ -64,54 +65,52 @@ export default function ListDivision() {
|
|||||||
search: search,
|
search: search,
|
||||||
group: String(group),
|
group: String(group),
|
||||||
kategori: category,
|
kategori: category,
|
||||||
page: thisPage
|
page: pageParam
|
||||||
});
|
});
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
initialPageParam: 1,
|
||||||
|
getNextPageParam: (lastPage, allPages) => {
|
||||||
|
return lastPage.data.length > 0 ? allPages.length + 1 : undefined;
|
||||||
|
},
|
||||||
|
enabled: !!token?.current,
|
||||||
|
staleTime: 0,
|
||||||
|
})
|
||||||
|
|
||||||
if (response.success) {
|
// Refetch when manual update state changes
|
||||||
if (thisPage == 1) {
|
|
||||||
setData(response.data);
|
|
||||||
} else if (thisPage > 1 && response.data.length > 0) {
|
|
||||||
setData([...data, ...response.data]);
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setNameGroup(response.filter.name);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
setWaiting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handleLoad(false, 1);
|
refetch()
|
||||||
}, [update]);
|
}, [update, refetch])
|
||||||
|
|
||||||
useEffect(() => {
|
// Flatten pages into a single data array
|
||||||
handleLoad(true, 1);
|
const flatData = useMemo(() => {
|
||||||
}, [status, search, group, category]);
|
return data?.pages.flatMap(page => page.data) || [];
|
||||||
|
}, [data])
|
||||||
|
|
||||||
const loadMoreData = () => {
|
// Get nameGroup from the first available page
|
||||||
if (waiting) return
|
const nameGroup = useMemo(() => {
|
||||||
setTimeout(() => {
|
return data?.pages[0]?.filter?.name || "";
|
||||||
handleLoad(false, page + 1)
|
}, [data])
|
||||||
}, 1000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
setRefreshing(true)
|
setRefreshing(true)
|
||||||
handleLoad(false, 1)
|
await queryClient.invalidateQueries({ queryKey: ['divisions'] })
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
||||||
setRefreshing(false)
|
setRefreshing(false)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadMoreData = () => {
|
||||||
|
if (hasNextPage && !isFetchingNextPage) {
|
||||||
|
fetchNextPage()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const arrSkeleton = [0, 1, 2]
|
||||||
|
|
||||||
const getItem = (_data: unknown, index: number): Props => ({
|
const getItem = (_data: unknown, index: number): Props => ({
|
||||||
id: data[index].id,
|
id: flatData[index]?.id,
|
||||||
name: data[index].name,
|
name: flatData[index]?.name,
|
||||||
desc: data[index].desc,
|
desc: flatData[index]?.desc,
|
||||||
jumlah_member: data[index].jumlah_member,
|
jumlah_member: flatData[index]?.jumlah_member,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -206,7 +205,7 @@ export default function ListDivision() {
|
|||||||
</View>
|
</View>
|
||||||
<View style={[{ flex: 2 }, Styles.mt10]}>
|
<View style={[{ flex: 2 }, Styles.mt10]}>
|
||||||
{
|
{
|
||||||
loading ?
|
isLoading ?
|
||||||
isList ?
|
isList ?
|
||||||
arrSkeleton.map((item, index) => (
|
arrSkeleton.map((item, index) => (
|
||||||
<SkeletonTwoItem key={index} />
|
<SkeletonTwoItem key={index} />
|
||||||
@@ -216,7 +215,7 @@ export default function ListDivision() {
|
|||||||
<Skeleton key={index} width={100} height={180} widthType="percent" borderRadius={10} />
|
<Skeleton key={index} width={100} height={180} widthType="percent" borderRadius={10} />
|
||||||
))
|
))
|
||||||
:
|
:
|
||||||
data.length == 0 ? (
|
flatData.length == 0 ? (
|
||||||
<View style={[Styles.mt15]}>
|
<View style={[Styles.mt15]}>
|
||||||
<Text style={[Styles.textDefault, { textAlign: 'center', color: colors.dimmed }]}>Tidak ada data</Text>
|
<Text style={[Styles.textDefault, { textAlign: 'center', color: colors.dimmed }]}>Tidak ada data</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -224,9 +223,9 @@ export default function ListDivision() {
|
|||||||
isList ? (
|
isList ? (
|
||||||
<View style={[Styles.h100]}>
|
<View style={[Styles.h100]}>
|
||||||
<VirtualizedList
|
<VirtualizedList
|
||||||
data={data}
|
data={flatData}
|
||||||
style={[{ paddingBottom: 100 }]}
|
style={[{ paddingBottom: 100 }]}
|
||||||
getItemCount={() => data.length}
|
getItemCount={() => flatData.length}
|
||||||
getItem={getItem}
|
getItem={getItem}
|
||||||
renderItem={({ item, index }: { item: Props, index: number }) => {
|
renderItem={({ item, index }: { item: Props, index: number }) => {
|
||||||
return (
|
return (
|
||||||
@@ -260,9 +259,9 @@ export default function ListDivision() {
|
|||||||
) : (
|
) : (
|
||||||
<View style={[Styles.h100]}>
|
<View style={[Styles.h100]}>
|
||||||
<VirtualizedList
|
<VirtualizedList
|
||||||
data={data}
|
data={flatData}
|
||||||
style={[{ paddingBottom: 100 }]}
|
style={[{ paddingBottom: 100 }]}
|
||||||
getItemCount={() => data.length}
|
getItemCount={() => flatData.length}
|
||||||
getItem={getItem}
|
getItem={getItem}
|
||||||
renderItem={({ item, index }: { item: Props, index: number }) => {
|
renderItem={({ item, index }: { item: Props, index: number }) => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import { setUpdateGroup } from "@/lib/groupSlice";
|
|||||||
import { useAuthSession } from "@/providers/AuthProvider";
|
import { useAuthSession } from "@/providers/AuthProvider";
|
||||||
import { useTheme } from "@/providers/ThemeProvider";
|
import { useTheme } from "@/providers/ThemeProvider";
|
||||||
import { AntDesign, Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
import { AntDesign, Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
import { useEffect, useState } from "react";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { RefreshControl, View, VirtualizedList } from "react-native";
|
import { RefreshControl, View, VirtualizedList } from "react-native";
|
||||||
import Toast from "react-native-toast-message";
|
import Toast from "react-native-toast-message";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
@@ -31,16 +32,14 @@ export default function Index() {
|
|||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const [isModal, setModal] = useState(false)
|
const [isModal, setModal] = useState(false)
|
||||||
const [isVisibleEdit, setVisibleEdit] = useState(false)
|
const [isVisibleEdit, setVisibleEdit] = useState(false)
|
||||||
const [data, setData] = useState<Props[]>([])
|
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const arrSkeleton = Array.from({ length: 5 }, (_, index) => index)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||||
const [status, setStatus] = useState<'true' | 'false'>('true')
|
const [status, setStatus] = useState<'true' | 'false'>('true')
|
||||||
const [loadingSubmit, setLoadingSubmit] = useState(false)
|
const [loadingSubmit, setLoadingSubmit] = useState(false)
|
||||||
const [idChoose, setIdChoose] = useState('')
|
const [idChoose, setIdChoose] = useState('')
|
||||||
const [activeChoose, setActiveChoose] = useState(true)
|
const [activeChoose, setActiveChoose] = useState(true)
|
||||||
const [titleChoose, setTitleChoose] = useState('')
|
const [titleChoose, setTitleChoose] = useState('')
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
@@ -49,12 +48,38 @@ export default function Index() {
|
|||||||
title: false,
|
title: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TanStack Query for Groups
|
||||||
|
const {
|
||||||
|
data: queryData,
|
||||||
|
isLoading,
|
||||||
|
refetch
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['groups', { status, search }],
|
||||||
|
queryFn: async () => {
|
||||||
|
const hasil = await decryptToken(String(token?.current))
|
||||||
|
const response = await apiGetGroup({
|
||||||
|
user: hasil,
|
||||||
|
active: status,
|
||||||
|
search: search
|
||||||
|
})
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
enabled: !!token?.current,
|
||||||
|
staleTime: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = useMemo(() => queryData?.data || [], [queryData])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refetch()
|
||||||
|
}, [update, refetch])
|
||||||
|
|
||||||
async function handleEdit() {
|
async function handleEdit() {
|
||||||
try {
|
try {
|
||||||
setLoadingSubmit(true)
|
setLoadingSubmit(true)
|
||||||
const hasil = await decryptToken(String(token?.current))
|
const hasil = await decryptToken(String(token?.current))
|
||||||
const response = await apiEditGroup({ user: hasil, name: titleChoose }, idChoose)
|
const response = await apiEditGroup({ user: hasil, name: titleChoose }, idChoose)
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['groups'] })
|
||||||
dispatch(setUpdateGroup(!update))
|
dispatch(setUpdateGroup(!update))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
@@ -71,6 +96,7 @@ export default function Index() {
|
|||||||
try {
|
try {
|
||||||
const hasil = await decryptToken(String(token?.current))
|
const hasil = await decryptToken(String(token?.current))
|
||||||
const response = await apiDeleteGroup({ user: hasil, isActive: activeChoose }, idChoose)
|
const response = await apiDeleteGroup({ user: hasil, isActive: activeChoose }, idChoose)
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['groups'] })
|
||||||
dispatch(setUpdateGroup(!update))
|
dispatch(setUpdateGroup(!update))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
@@ -80,32 +106,9 @@ export default function Index() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLoad(loading: boolean) {
|
|
||||||
try {
|
|
||||||
setLoading(loading)
|
|
||||||
const hasil = await decryptToken(String(token?.current))
|
|
||||||
const response = await apiGetGroup({ user: hasil, active: status, search: search })
|
|
||||||
setData(response.data)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
handleLoad(false)
|
|
||||||
}, [update])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
handleLoad(true)
|
|
||||||
}, [status, search])
|
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
setRefreshing(true)
|
setRefreshing(true)
|
||||||
handleLoad(false)
|
await queryClient.invalidateQueries({ queryKey: ['groups'] })
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
||||||
setRefreshing(false)
|
setRefreshing(false)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -129,6 +132,8 @@ export default function Index() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const arrSkeleton = [0, 1, 2, 3, 4]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[Styles.p15, { flex: 1, backgroundColor: colors.background }]}>
|
<View style={[Styles.p15, { flex: 1, backgroundColor: colors.background }]}>
|
||||||
<View style={[Styles.mb10]}>
|
<View style={[Styles.mb10]}>
|
||||||
@@ -152,7 +157,7 @@ export default function Index() {
|
|||||||
</View>
|
</View>
|
||||||
<View style={[{ flex: 2 }, Styles.mt10]}>
|
<View style={[{ flex: 2 }, Styles.mt10]}>
|
||||||
{
|
{
|
||||||
loading ?
|
isLoading ?
|
||||||
arrSkeleton.map((item, index) => {
|
arrSkeleton.map((item, index) => {
|
||||||
return (
|
return (
|
||||||
<SkeletonTwoItem key={index} />
|
<SkeletonTwoItem key={index} />
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ import { apiGetProfile } from "@/lib/api";
|
|||||||
import { setEntities } from "@/lib/entitiesSlice";
|
import { setEntities } from "@/lib/entitiesSlice";
|
||||||
import { useAuthSession } from "@/providers/AuthProvider";
|
import { useAuthSession } from "@/providers/AuthProvider";
|
||||||
import { useTheme } from "@/providers/ThemeProvider";
|
import { useTheme } from "@/providers/ThemeProvider";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Dimensions, Platform, RefreshControl, SafeAreaView, ScrollView, View } from "react-native";
|
import { Alert, Dimensions, Platform, RefreshControl, SafeAreaView, ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
|
||||||
@@ -23,28 +24,66 @@ import { useDispatch, useSelector } from "react-redux";
|
|||||||
export default function Home() {
|
export default function Home() {
|
||||||
const entities = useSelector((state: any) => state.entities)
|
const entities = useSelector((state: any) => state.entities)
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const { token, decryptToken, signOut } = useAuthSession()
|
const { token, decryptToken, signOut } = useAuthSession()
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
const { data: profile, isError } = useQuery({
|
||||||
handleUserLogin()
|
queryKey: ['profile'],
|
||||||
}, [dispatch]);
|
queryFn: async () => {
|
||||||
|
const hasil = await decryptToken(String(token?.current))
|
||||||
|
const data = await apiGetProfile({ id: hasil })
|
||||||
|
return data.data
|
||||||
|
},
|
||||||
|
enabled: !!token?.current,
|
||||||
|
staleTime: 0, // Ensure it refetches every time the component mounts
|
||||||
|
})
|
||||||
|
|
||||||
async function handleUserLogin() {
|
// Sync to Redux for global usage
|
||||||
const hasil = await decryptToken(String(token?.current))
|
useEffect(() => {
|
||||||
apiGetProfile({ id: hasil })
|
if (profile) {
|
||||||
.then((data) => dispatch(setEntities(data.data)))
|
dispatch(setEntities(profile))
|
||||||
.catch((error) => {
|
}
|
||||||
signOut()
|
}, [profile, dispatch])
|
||||||
});
|
|
||||||
}
|
// Auto Sign Out if profile fetch fails (e.g. invalid/expired token)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isError) {
|
||||||
|
signOut()
|
||||||
|
}
|
||||||
|
}, [isError, signOut])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (profile && profile.isActive === false) {
|
||||||
|
Alert.alert(
|
||||||
|
'Akun Dinonaktifkan',
|
||||||
|
'Akun kamu telah dinonaktifkan. Silahkan hubungi admin untuk informasi lebih lanjut.',
|
||||||
|
[{ text: 'OK', onPress: signOut }]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [profile, signOut])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (profile && profile.villageIsActive === false) {
|
||||||
|
Alert.alert(
|
||||||
|
'Desa Dinonaktifkan',
|
||||||
|
'Desa kamu saat ini telah dinonaktifkan. Silahkan hubungi pengelola sistem untuk informasi lebih lanjut.',
|
||||||
|
[{ text: 'OK', onPress: signOut }]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [profile, signOut])
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
setRefreshing(true)
|
setRefreshing(true)
|
||||||
handleUserLogin()
|
// Invalidate all queries related to the home screen
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
await queryClient.invalidateQueries({ queryKey: ['profile'] })
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['banners'] })
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['homeData'] })
|
||||||
|
|
||||||
|
// Artificial delay to show refresh indicator if sync is too fast
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
setRefreshing(false)
|
setRefreshing(false)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ import { apiGetUser } from "@/lib/api";
|
|||||||
import { useAuthSession } from "@/providers/AuthProvider";
|
import { useAuthSession } from "@/providers/AuthProvider";
|
||||||
import { useTheme } from "@/providers/ThemeProvider";
|
import { useTheme } from "@/providers/ThemeProvider";
|
||||||
import { AntDesign, Feather } from "@expo/vector-icons";
|
import { AntDesign, Feather } from "@expo/vector-icons";
|
||||||
|
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { router, useLocalSearchParams } from "expo-router";
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { RefreshControl, View, VirtualizedList } from "react-native";
|
import { RefreshControl, View, VirtualizedList } from "react-native";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
|
|
||||||
@@ -37,73 +38,81 @@ export default function Index() {
|
|||||||
const entityUser = useSelector((state: any) => state.user)
|
const entityUser = useSelector((state: any) => state.user)
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [nameGroup, setNameGroup] = useState('')
|
|
||||||
const [data, setData] = useState<Props[]>([])
|
|
||||||
const update = useSelector((state: any) => state.memberUpdate)
|
const update = useSelector((state: any) => state.memberUpdate)
|
||||||
const arrSkeleton = Array.from({ length: 5 }, (_, index) => index)
|
const queryClient = useQueryClient()
|
||||||
const [loading, setLoading] = useState(true)
|
const [status, setStatus] = useState<'true' | 'false'>(active == 'false' ? 'false' : 'true')
|
||||||
const [status, setStatus] = useState<'true' | 'false'>('true')
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [waiting, setWaiting] = useState(false)
|
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
|
||||||
async function handleLoad(loading: boolean, thisPage: number) {
|
// TanStack Query for Members with Infinite Scroll
|
||||||
try {
|
const {
|
||||||
setWaiting(true)
|
data,
|
||||||
setLoading(loading)
|
fetchNextPage,
|
||||||
setPage(thisPage)
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
isLoading,
|
||||||
|
refetch
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ['members', { status, search, group }],
|
||||||
|
queryFn: async ({ pageParam = 1 }) => {
|
||||||
const hasil = await decryptToken(String(token?.current))
|
const hasil = await decryptToken(String(token?.current))
|
||||||
const response = await apiGetUser({ user: hasil, active: status, search, group: String(group), page: thisPage })
|
const response = await apiGetUser({
|
||||||
if (thisPage == 1) {
|
user: hasil,
|
||||||
setData(response.data)
|
active: status,
|
||||||
} else if (thisPage > 1 && response.data.length > 0) {
|
search,
|
||||||
setData([...data, ...response.data])
|
group: String(group),
|
||||||
} else {
|
page: pageParam
|
||||||
return;
|
})
|
||||||
}
|
return response;
|
||||||
setNameGroup(response.filter.name)
|
},
|
||||||
} catch (error) {
|
initialPageParam: 1,
|
||||||
console.error(error)
|
getNextPageParam: (lastPage, allPages) => {
|
||||||
} finally {
|
return lastPage.data.length > 0 ? allPages.length + 1 : undefined;
|
||||||
setLoading(false)
|
},
|
||||||
setWaiting(false)
|
enabled: !!token?.current,
|
||||||
}
|
staleTime: 0,
|
||||||
}
|
})
|
||||||
|
|
||||||
const loadMoreData = () => {
|
// Flatten pages into a single data array
|
||||||
if (waiting) return
|
const flatData = useMemo(() => {
|
||||||
setTimeout(() => {
|
return data?.pages.flatMap(page => page.data) || [];
|
||||||
handleLoad(false, page + 1)
|
}, [data])
|
||||||
}, 1000);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// Get nameGroup from the first available page
|
||||||
|
const nameGroup = useMemo(() => {
|
||||||
|
return data?.pages[0]?.filter?.name || "";
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
// Refetch when manual update state changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handleLoad(false, 1)
|
refetch()
|
||||||
}, [update])
|
}, [update, refetch])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
handleLoad(true, 1)
|
|
||||||
}, [group, search, status])
|
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
setRefreshing(true)
|
setRefreshing(true)
|
||||||
handleLoad(false, 1)
|
await queryClient.invalidateQueries({ queryKey: ['members'] })
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
||||||
setRefreshing(false)
|
setRefreshing(false)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadMoreData = () => {
|
||||||
|
if (hasNextPage && !isFetchingNextPage) {
|
||||||
|
fetchNextPage()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const arrSkeleton = [0, 1, 2, 3, 4]
|
||||||
|
|
||||||
const getItem = (_data: unknown, index: number): Props => ({
|
const getItem = (_data: unknown, index: number): Props => ({
|
||||||
id: data[index].id,
|
id: flatData[index]?.id,
|
||||||
name: data[index].name,
|
name: flatData[index]?.name,
|
||||||
nik: data[index].nik,
|
nik: flatData[index]?.nik,
|
||||||
email: data[index].email,
|
email: flatData[index]?.email,
|
||||||
phone: data[index].phone,
|
phone: flatData[index]?.phone,
|
||||||
gender: data[index].gender,
|
gender: flatData[index]?.gender,
|
||||||
position: data[index].position,
|
position: flatData[index]?.position,
|
||||||
group: data[index].group,
|
group: flatData[index]?.group,
|
||||||
img: data[index].img,
|
img: flatData[index]?.img,
|
||||||
isActive: data[index].isActive,
|
isActive: flatData[index]?.isActive,
|
||||||
role: data[index].role,
|
role: flatData[index]?.role,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -136,18 +145,18 @@ export default function Index() {
|
|||||||
</View>
|
</View>
|
||||||
<View style={[{ flex: 2 }, Styles.mt10]}>
|
<View style={[{ flex: 2 }, Styles.mt10]}>
|
||||||
{
|
{
|
||||||
loading ?
|
isLoading ?
|
||||||
arrSkeleton.map((item, index) => {
|
arrSkeleton.map((item, index) => {
|
||||||
return (
|
return (
|
||||||
<SkeletonTwoItem key={index} />
|
<SkeletonTwoItem key={index} />
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
:
|
:
|
||||||
data.length > 0
|
flatData.length > 0
|
||||||
?
|
?
|
||||||
<VirtualizedList
|
<VirtualizedList
|
||||||
data={data}
|
data={flatData}
|
||||||
getItemCount={() => data.length}
|
getItemCount={() => flatData.length}
|
||||||
getItem={getItem}
|
getItem={getItem}
|
||||||
renderItem={({ item, index }: { item: Props, index: number }) => {
|
renderItem={({ item, index }: { item: Props, index: number }) => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import BorderBottomItem from "@/components/borderBottomItem";
|
|
||||||
import BorderBottomItemVertical from "@/components/borderBottomItemVertical";
|
import BorderBottomItemVertical from "@/components/borderBottomItemVertical";
|
||||||
import SkeletonTwoItem from "@/components/skeletonTwoItem";
|
import SkeletonTwoItem from "@/components/skeletonTwoItem";
|
||||||
import Text from "@/components/Text";
|
import Text from "@/components/Text";
|
||||||
@@ -10,7 +9,8 @@ import { pushToPage } from "@/lib/pushToPage";
|
|||||||
import { useAuthSession } from "@/providers/AuthProvider";
|
import { useAuthSession } from "@/providers/AuthProvider";
|
||||||
import { useTheme } from "@/providers/ThemeProvider";
|
import { useTheme } from "@/providers/ThemeProvider";
|
||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import { useEffect, useState } from "react";
|
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { RefreshControl, SafeAreaView, View, VirtualizedList } from "react-native";
|
import { RefreshControl, SafeAreaView, View, VirtualizedList } from "react-native";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
|
||||||
@@ -27,64 +27,61 @@ type Props = {
|
|||||||
export default function Notification() {
|
export default function Notification() {
|
||||||
const { token, decryptToken } = useAuthSession()
|
const { token, decryptToken } = useAuthSession()
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const [loading, setLoading] = useState(false)
|
const queryClient = useQueryClient()
|
||||||
const [data, setData] = useState<Props[]>([])
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [waiting, setWaiting] = useState(false)
|
|
||||||
const arrSkeleton = Array.from({ length: 5 }, (_, index) => index)
|
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
const updateNotification = useSelector((state: any) => state.notificationUpdate)
|
const updateNotification = useSelector((state: any) => state.notificationUpdate)
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
|
||||||
async function handleLoad(loading: boolean, thisPage: number) {
|
// TanStack Query for Notifications with Infinite Scroll
|
||||||
try {
|
const {
|
||||||
setLoading(loading)
|
data,
|
||||||
setPage(thisPage)
|
fetchNextPage,
|
||||||
setWaiting(true)
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
isLoading,
|
||||||
|
refetch
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ['notifications'],
|
||||||
|
queryFn: async ({ pageParam = 1 }) => {
|
||||||
const hasil = await decryptToken(String(token?.current))
|
const hasil = await decryptToken(String(token?.current))
|
||||||
const response = await apiGetNotification({ user: hasil, page: thisPage })
|
const response = await apiGetNotification({ user: hasil, page: pageParam })
|
||||||
if (thisPage == 1) {
|
return response;
|
||||||
setData(response.data)
|
},
|
||||||
} else if (thisPage > 1 && response.data.length > 0) {
|
initialPageParam: 1,
|
||||||
setData([...data, ...response.data])
|
getNextPageParam: (lastPage, allPages) => {
|
||||||
} else {
|
return lastPage.data.length > 0 ? allPages.length + 1 : undefined;
|
||||||
return;
|
},
|
||||||
}
|
enabled: !!token?.current,
|
||||||
} catch (error) {
|
staleTime: 0,
|
||||||
console.error(error)
|
})
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
setWaiting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Flatten pages into a single data array
|
||||||
|
const flatData = useMemo(() => {
|
||||||
|
return data?.pages.flatMap(page => page.data) || [];
|
||||||
|
}, [data])
|
||||||
|
|
||||||
const loadMoreData = () => {
|
// Refetch when manual update state changes
|
||||||
if (waiting) return
|
useEffect(() => {
|
||||||
setTimeout(() => {
|
refetch()
|
||||||
handleLoad(false, page + 1)
|
}, [updateNotification, refetch])
|
||||||
}, 1000);
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
setRefreshing(true)
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['notifications'] })
|
||||||
|
setRefreshing(false)
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const loadMoreData = () => {
|
||||||
handleLoad(true, 1)
|
if (hasNextPage && !isFetchingNextPage) {
|
||||||
}, [])
|
fetchNextPage()
|
||||||
|
}
|
||||||
|
};
|
||||||
const getItem = (_data: unknown, index: number): Props => ({
|
|
||||||
id: data[index].id,
|
|
||||||
title: data[index].title,
|
|
||||||
desc: data[index].desc,
|
|
||||||
category: data[index].category,
|
|
||||||
idContent: data[index].idContent,
|
|
||||||
isRead: data[index].isRead,
|
|
||||||
createdAt: data[index].createdAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handleReadNotification(id: string, category: string, idContent: string) {
|
async function handleReadNotification(id: string, category: string, idContent: string) {
|
||||||
try {
|
try {
|
||||||
const hasil = await decryptToken(String(token?.current))
|
const hasil = await decryptToken(String(token?.current))
|
||||||
const response = await apiReadOneNotification({ user: hasil, id: id })
|
const response = await apiReadOneNotification({ user: hasil, id: id })
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['notifications'] })
|
||||||
pushToPage(category, idContent)
|
pushToPage(category, idContent)
|
||||||
dispatch(setUpdateNotification(!updateNotification))
|
dispatch(setUpdateNotification(!updateNotification))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -92,28 +89,33 @@ export default function Notification() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const arrSkeleton = [0, 1, 2, 3, 4]
|
||||||
setRefreshing(true)
|
|
||||||
handleLoad(false, 1)
|
const getItem = (_data: unknown, index: number): Props => ({
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
id: flatData[index]?.id,
|
||||||
setRefreshing(false)
|
title: flatData[index]?.title,
|
||||||
};
|
desc: flatData[index]?.desc,
|
||||||
|
category: flatData[index]?.category,
|
||||||
|
idContent: flatData[index]?.idContent,
|
||||||
|
isRead: flatData[index]?.isRead,
|
||||||
|
createdAt: flatData[index]?.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
|
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
|
||||||
<View style={[Styles.p15]}>
|
<View style={[Styles.p15]}>
|
||||||
{
|
{
|
||||||
loading ?
|
isLoading ?
|
||||||
arrSkeleton.map((item, index) => {
|
arrSkeleton.map((item, index) => {
|
||||||
return (
|
return (
|
||||||
<SkeletonTwoItem key={index} />
|
<SkeletonTwoItem key={index} />
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
:
|
:
|
||||||
data.length > 0 ?
|
flatData.length > 0 ?
|
||||||
<VirtualizedList
|
<VirtualizedList
|
||||||
data={data}
|
data={flatData}
|
||||||
getItemCount={() => data.length}
|
getItemCount={() => flatData.length}
|
||||||
getItem={getItem}
|
getItem={getItem}
|
||||||
renderItem={({ item, index }: { item: Props, index: number }) => {
|
renderItem={({ item, index }: { item: Props, index: number }) => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ import { setUpdatePosition } from "@/lib/positionSlice";
|
|||||||
import { useAuthSession } from "@/providers/AuthProvider";
|
import { useAuthSession } from "@/providers/AuthProvider";
|
||||||
import { useTheme } from "@/providers/ThemeProvider";
|
import { useTheme } from "@/providers/ThemeProvider";
|
||||||
import { AntDesign, Feather, MaterialCommunityIcons } from "@expo/vector-icons";
|
import { AntDesign, Feather, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { RefreshControl, View, VirtualizedList } from "react-native";
|
import { RefreshControl, View, VirtualizedList } from "react-native";
|
||||||
import Toast from "react-native-toast-message";
|
import Toast from "react-native-toast-message";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
@@ -31,51 +32,53 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const arrSkeleton = Array.from({ length: 5 }, (_, index) => index)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const { token, decryptToken } = useAuthSession()
|
const { token, decryptToken } = useAuthSession()
|
||||||
const { colors } = useTheme()
|
const { colors } = useTheme()
|
||||||
const [status, setStatus] = useState<'true' | 'false'>('true')
|
|
||||||
const entityUser = useSelector((state: any) => state.user)
|
|
||||||
const { active, group } = useLocalSearchParams<{ active?: string, group?: string }>()
|
const { active, group } = useLocalSearchParams<{ active?: string, group?: string }>()
|
||||||
|
const [status, setStatus] = useState<'true' | 'false'>(active == 'false' ? 'false' : 'true')
|
||||||
|
const entityUser = useSelector((state: any) => state.user)
|
||||||
const [isModal, setModal] = useState(false)
|
const [isModal, setModal] = useState(false)
|
||||||
const [isVisibleEdit, setVisibleEdit] = useState(false)
|
const [isVisibleEdit, setVisibleEdit] = useState(false)
|
||||||
const [data, setData] = useState<Props[]>([])
|
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [nameGroup, setNameGroup] = useState('')
|
|
||||||
const [loadingSubmit, setLoadingSubmit] = useState(false)
|
const [loadingSubmit, setLoadingSubmit] = useState(false)
|
||||||
const [chooseData, setChooseData] = useState({ name: '', id: '', active: false, idGroup: '' })
|
const [chooseData, setChooseData] = useState({ name: '', id: '', active: false, idGroup: '' })
|
||||||
const [error, setError] = useState({
|
const [error, setError] = useState({
|
||||||
name: false,
|
name: false,
|
||||||
});
|
});
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||||
|
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
const update = useSelector((state: any) => state.positionUpdate)
|
const update = useSelector((state: any) => state.positionUpdate)
|
||||||
|
|
||||||
async function handleLoad(loading: boolean) {
|
// TanStack Query for Positions
|
||||||
try {
|
const {
|
||||||
setLoading(loading)
|
data: queryData,
|
||||||
|
isLoading,
|
||||||
|
refetch
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['positions', { status, search, group }],
|
||||||
|
queryFn: async () => {
|
||||||
const hasil = await decryptToken(String(token?.current))
|
const hasil = await decryptToken(String(token?.current))
|
||||||
const response = await apiGetPosition({ user: hasil, active: status, search: search, group: String(group) })
|
const response = await apiGetPosition({
|
||||||
setData(response.data)
|
user: hasil,
|
||||||
setNameGroup(response.filter.name)
|
active: status,
|
||||||
} catch (error) {
|
search: search,
|
||||||
console.error(error)
|
group: String(group)
|
||||||
} finally {
|
})
|
||||||
setLoading(false)
|
return response;
|
||||||
}
|
},
|
||||||
}
|
enabled: !!token?.current,
|
||||||
|
staleTime: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = useMemo(() => queryData?.data || [], [queryData])
|
||||||
|
const nameGroup = useMemo(() => queryData?.filter?.name || "", [queryData])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handleLoad(false)
|
refetch()
|
||||||
}, [update])
|
}, [update, refetch])
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
handleLoad(true)
|
|
||||||
}, [status, search, group])
|
|
||||||
|
|
||||||
|
|
||||||
function handleChooseData(id: string, name: string, active: boolean, group: string) {
|
function handleChooseData(id: string, name: string, active: boolean, group: string) {
|
||||||
@@ -88,7 +91,7 @@ export default function Index() {
|
|||||||
const hasil = await decryptToken(String(token?.current))
|
const hasil = await decryptToken(String(token?.current))
|
||||||
const response = await apiDeletePosition({ user: hasil, isActive: chooseData.active }, chooseData.id)
|
const response = await apiDeletePosition({ user: hasil, isActive: chooseData.active }, chooseData.id)
|
||||||
dispatch(setUpdatePosition(!update))
|
dispatch(setUpdatePosition(!update))
|
||||||
} catch (error : any ) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
const message = error?.response?.data?.message || "Gagal menghapus data"
|
const message = error?.response?.data?.message || "Gagal menghapus data"
|
||||||
|
|
||||||
@@ -110,7 +113,7 @@ export default function Index() {
|
|||||||
} else {
|
} else {
|
||||||
Toast.show({ type: 'small', text1: response.message, })
|
Toast.show({ type: 'small', text1: response.message, })
|
||||||
}
|
}
|
||||||
} catch (error : any ) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
const message = error?.response?.data?.message || "Gagal mengubah data"
|
const message = error?.response?.data?.message || "Gagal mengubah data"
|
||||||
|
|
||||||
@@ -138,10 +141,11 @@ export default function Index() {
|
|||||||
handleEdit()
|
handleEdit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const arrSkeleton = [0, 1, 2, 3, 4]
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
setRefreshing(true)
|
setRefreshing(true)
|
||||||
handleLoad(false)
|
await queryClient.invalidateQueries({ queryKey: ['positions'] })
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
||||||
setRefreshing(false)
|
setRefreshing(false)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -184,7 +188,7 @@ export default function Index() {
|
|||||||
</View>
|
</View>
|
||||||
<View style={[Styles.flex2, Styles.mt10]}>
|
<View style={[Styles.flex2, Styles.mt10]}>
|
||||||
{
|
{
|
||||||
loading ?
|
isLoading ?
|
||||||
arrSkeleton.map((item, index) => {
|
arrSkeleton.map((item, index) => {
|
||||||
return (
|
return (
|
||||||
<SkeletonTwoItem key={index} />
|
<SkeletonTwoItem key={index} />
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ import {
|
|||||||
Ionicons,
|
Ionicons,
|
||||||
MaterialCommunityIcons,
|
MaterialCommunityIcons,
|
||||||
} from "@expo/vector-icons";
|
} from "@expo/vector-icons";
|
||||||
|
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { router, useLocalSearchParams } from "expo-router";
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Pressable, RefreshControl, ScrollView, View, VirtualizedList } from "react-native";
|
import { Pressable, RefreshControl, ScrollView, View, VirtualizedList } from "react-native";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
|
|
||||||
@@ -40,28 +41,29 @@ export default function ListProject() {
|
|||||||
cat?: string;
|
cat?: string;
|
||||||
year?: string;
|
year?: string;
|
||||||
}>();
|
}>();
|
||||||
const [statusFix, setStatusFix] = useState<'0' | '1' | '2' | '3'>('0')
|
const [statusFix, setStatusFix] = useState<'0' | '1' | '2' | '3'>(
|
||||||
|
(status == '1' || status == '2' || status == '3') ? status : '0'
|
||||||
|
)
|
||||||
const { token, decryptToken } = useAuthSession();
|
const { token, decryptToken } = useAuthSession();
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const entityUser = useSelector((state: any) => state.user)
|
const entityUser = useSelector((state: any) => state.user)
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [nameGroup, setNameGroup] = useState("")
|
|
||||||
// ... state same ...
|
|
||||||
const [isYear, setYear] = useState("")
|
|
||||||
const [data, setData] = useState<Props[]>([])
|
|
||||||
const [isList, setList] = useState(false)
|
const [isList, setList] = useState(false)
|
||||||
const update = useSelector((state: any) => state.projectUpdate)
|
const update = useSelector((state: any) => state.projectUpdate)
|
||||||
const [loading, setLoading] = useState(true)
|
const queryClient = useQueryClient()
|
||||||
const arrSkeleton = Array.from({ length: 3 }, (_, index) => index)
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [waiting, setWaiting] = useState(false)
|
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
|
||||||
async function handleLoad(loading: boolean, thisPage: number) {
|
// TanStack Query for Projects with Infinite Scroll
|
||||||
try {
|
const {
|
||||||
setLoading(loading)
|
data,
|
||||||
setWaiting(true)
|
fetchNextPage,
|
||||||
setPage(thisPage)
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
isLoading,
|
||||||
|
refetch
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ['projects', { statusFix, search, group, cat, year }],
|
||||||
|
queryFn: async ({ pageParam = 1 }) => {
|
||||||
const hasil = await decryptToken(String(token?.current));
|
const hasil = await decryptToken(String(token?.current));
|
||||||
const response = await apiGetProject({
|
const response = await apiGetProject({
|
||||||
user: hasil,
|
user: hasil,
|
||||||
@@ -69,60 +71,55 @@ export default function ListProject() {
|
|||||||
search: search,
|
search: search,
|
||||||
group: String(group),
|
group: String(group),
|
||||||
kategori: String(cat),
|
kategori: String(cat),
|
||||||
page: thisPage,
|
page: pageParam,
|
||||||
year: String(year)
|
year: String(year)
|
||||||
});
|
});
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
initialPageParam: 1,
|
||||||
|
getNextPageParam: (lastPage, allPages) => {
|
||||||
|
return lastPage.data.length > 0 ? allPages.length + 1 : undefined;
|
||||||
|
},
|
||||||
|
enabled: !!token?.current,
|
||||||
|
staleTime: 0,
|
||||||
|
})
|
||||||
|
|
||||||
if (response.success) {
|
// Refetch when manual update state changes
|
||||||
setNameGroup(response.filter.name);
|
|
||||||
setYear(response.tahun)
|
|
||||||
if (thisPage == 1) {
|
|
||||||
setData(response.data);
|
|
||||||
} else if (thisPage > 1 && response.data.length > 0) {
|
|
||||||
setData([...data, ...response.data])
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
setWaiting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handleLoad(false, 1);
|
refetch()
|
||||||
}, [update.data]);
|
}, [update.data, refetch])
|
||||||
|
|
||||||
|
// Flatten pages into a single data array
|
||||||
|
const flatData = useMemo(() => {
|
||||||
|
return data?.pages.flatMap(page => page.data) || [];
|
||||||
|
}, [data])
|
||||||
|
|
||||||
useEffect(() => {
|
// Get metadata from the first available page
|
||||||
handleLoad(true, 1);
|
const nameGroup = useMemo(() => data?.pages[0]?.filter?.name || "", [data])
|
||||||
}, [statusFix, search, group, cat, year]);
|
const isYear = useMemo(() => data?.pages[0]?.tahun || "", [data])
|
||||||
|
|
||||||
const loadMoreData = () => {
|
|
||||||
if (waiting) return
|
|
||||||
setTimeout(() => {
|
|
||||||
handleLoad(false, page + 1)
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
setRefreshing(true)
|
setRefreshing(true)
|
||||||
handleLoad(false, 1)
|
await queryClient.invalidateQueries({ queryKey: ['projects'] })
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
||||||
setRefreshing(false)
|
setRefreshing(false)
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const loadMoreData = () => {
|
||||||
|
if (hasNextPage && !isFetchingNextPage) {
|
||||||
|
fetchNextPage()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const arrSkeleton = [0, 1, 2]
|
||||||
|
|
||||||
const getItem = (_data: unknown, index: number): Props => ({
|
const getItem = (_data: unknown, index: number): Props => ({
|
||||||
id: data[index].id,
|
id: flatData[index]?.id,
|
||||||
title: data[index].title,
|
title: flatData[index]?.title,
|
||||||
desc: data[index].desc,
|
desc: flatData[index]?.desc,
|
||||||
status: data[index].status,
|
status: flatData[index]?.status,
|
||||||
member: data[index].member,
|
member: flatData[index]?.member,
|
||||||
progress: data[index].progress,
|
progress: flatData[index]?.progress,
|
||||||
createdAt: data[index].createdAt,
|
createdAt: flatData[index]?.createdAt,
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -205,7 +202,6 @@ export default function ListProject() {
|
|||||||
</View>
|
</View>
|
||||||
<View style={[Styles.mt10]}>
|
<View style={[Styles.mt10]}>
|
||||||
{
|
{
|
||||||
// entityUser.role != 'cosupadmin' && entityUser.role != 'admin' &&
|
|
||||||
<View style={[Styles.rowOnly]}>
|
<View style={[Styles.rowOnly]}>
|
||||||
<Text style={[Styles.mr05]}>Filter :</Text>
|
<Text style={[Styles.mr05]}>Filter :</Text>
|
||||||
{
|
{
|
||||||
@@ -218,18 +214,13 @@ export default function ListProject() {
|
|||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
<LabelStatus size="small" category="secondary" text={isYear} style={[Styles.mr05]} />
|
<LabelStatus size="small" category="secondary" text={isYear} style={[Styles.mr05]} />
|
||||||
{/* {
|
|
||||||
(entityUser.role == 'user' || entityUser.role == 'coadmin')
|
|
||||||
? (cat == 'null' || cat == 'undefined' || cat == undefined || cat == '' || cat == 'data-saya') ? <LabelStatus size="small" category="primary" text="Kegiatan Saya" /> : <LabelStatus size="small" category="primary" text="Semua Kegiatan" />
|
|
||||||
: ''
|
|
||||||
} */}
|
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View style={[Styles.flex2, Styles.mt10]}>
|
<View style={[Styles.flex2, Styles.mt10]}>
|
||||||
{
|
{
|
||||||
loading ?
|
isLoading ?
|
||||||
isList ?
|
isList ?
|
||||||
arrSkeleton.map((item, index) => (
|
arrSkeleton.map((item, index) => (
|
||||||
<SkeletonTwoItem key={index} />
|
<SkeletonTwoItem key={index} />
|
||||||
@@ -239,13 +230,13 @@ export default function ListProject() {
|
|||||||
<Skeleton key={index} width={100} height={180} widthType="percent" borderRadius={10} />
|
<Skeleton key={index} width={100} height={180} widthType="percent" borderRadius={10} />
|
||||||
))
|
))
|
||||||
:
|
:
|
||||||
data.length > 0
|
flatData.length > 0
|
||||||
?
|
?
|
||||||
isList ? (
|
isList ? (
|
||||||
<View style={[Styles.h100]}>
|
<View style={[Styles.h100]}>
|
||||||
<VirtualizedList
|
<VirtualizedList
|
||||||
data={data}
|
data={flatData}
|
||||||
getItemCount={() => data.length}
|
getItemCount={() => flatData.length}
|
||||||
getItem={getItem}
|
getItem={getItem}
|
||||||
renderItem={({ item, index }: { item: Props, index: number }) => {
|
renderItem={({ item, index }: { item: Props, index: number }) => {
|
||||||
return (
|
return (
|
||||||
@@ -279,35 +270,12 @@ export default function ListProject() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{/* {
|
|
||||||
data.map((item, index) => {
|
|
||||||
return (
|
|
||||||
<BorderBottomItem
|
|
||||||
key={index}
|
|
||||||
onPress={() => { router.push(`/project/${item.id}`); }}
|
|
||||||
borderType="bottom"
|
|
||||||
icon={
|
|
||||||
<View
|
|
||||||
style={[Styles.iconContent, ColorsStatus.lightGreen]}
|
|
||||||
>
|
|
||||||
<AntDesign
|
|
||||||
name="areachart"
|
|
||||||
size={25}
|
|
||||||
color={"#384288"}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={item.title}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
} */}
|
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={[Styles.h100]}>
|
<View style={[Styles.h100]}>
|
||||||
<VirtualizedList
|
<VirtualizedList
|
||||||
data={data}
|
data={flatData}
|
||||||
getItemCount={() => data.length}
|
getItemCount={() => flatData.length}
|
||||||
getItem={getItem}
|
getItem={getItem}
|
||||||
renderItem={({ item, index }: { item: Props, index: number }) => {
|
renderItem={({ item, index }: { item: Props, index: number }) => {
|
||||||
return (
|
return (
|
||||||
@@ -359,43 +327,6 @@ export default function ListProject() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{/* {data.map((item, index) => {
|
|
||||||
return (
|
|
||||||
<PaperGridContent
|
|
||||||
key={index}
|
|
||||||
onPress={() => {
|
|
||||||
router.push(`/project/${item.id}`);
|
|
||||||
}}
|
|
||||||
content="page"
|
|
||||||
title={item.title}
|
|
||||||
headerColor="primary"
|
|
||||||
>
|
|
||||||
<ProgressBar value={item.progress} category="list" />
|
|
||||||
<View style={[Styles.rowSpaceBetween]}>
|
|
||||||
<Text style={[Styles.textDefault, { color: colors.dimmed }]}>
|
|
||||||
{item.createdAt}
|
|
||||||
</Text>
|
|
||||||
<LabelStatus
|
|
||||||
size="default"
|
|
||||||
category={
|
|
||||||
item.status === 0 ? 'primary' :
|
|
||||||
item.status === 1 ? 'warning' :
|
|
||||||
item.status === 2 ? 'success' :
|
|
||||||
item.status === 3 ? 'error' :
|
|
||||||
'primary'
|
|
||||||
}
|
|
||||||
text={
|
|
||||||
item.status === 0 ? 'SEGERA' :
|
|
||||||
item.status === 1 ? 'DIKERJAKAN' :
|
|
||||||
item.status === 2 ? 'SELESAI' :
|
|
||||||
item.status === 3 ? 'DIBATALKAN' :
|
|
||||||
'SEGERA'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</PaperGridContent>
|
|
||||||
);
|
|
||||||
})} */}
|
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
:
|
:
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ import Text from "@/components/Text";
|
|||||||
import ButtonSetting from "@/components/buttonSetting";
|
import ButtonSetting from "@/components/buttonSetting";
|
||||||
import DrawerBottom from "@/components/drawerBottom";
|
import DrawerBottom from "@/components/drawerBottom";
|
||||||
import Styles from "@/constants/Styles";
|
import Styles from "@/constants/Styles";
|
||||||
import { apiRegisteredToken, apiUnregisteredToken } from "@/lib/api";
|
import { apiGetCheckToken, apiRegisteredToken, apiUnregisteredToken } from "@/lib/api";
|
||||||
import { checkPermission, getToken, openSettings, requestPermission } from "@/lib/useNotification";
|
import { checkPermission, getToken, openSettings } from "@/lib/useNotification";
|
||||||
import { useAuthSession } from "@/providers/AuthProvider";
|
import { useAuthSession } from "@/providers/AuthProvider";
|
||||||
import { useTheme } from "@/providers/ThemeProvider";
|
import { useTheme } from "@/providers/ThemeProvider";
|
||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import Constants from "expo-constants";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { AppState, AppStateStatus, Pressable, View } from "react-native";
|
import { AppState, AppStateStatus, Pressable, View } from "react-native";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
|
|
||||||
@@ -28,12 +30,14 @@ export default function ListSetting() {
|
|||||||
|
|
||||||
const [showLogoutModal, setShowLogoutModal] = useState(false)
|
const [showLogoutModal, setShowLogoutModal] = useState(false)
|
||||||
const [showThemeModal, setShowThemeModal] = useState(false)
|
const [showThemeModal, setShowThemeModal] = useState(false)
|
||||||
|
const prevOsPermission = useRef<boolean | undefined>(undefined);
|
||||||
|
|
||||||
const registerToken = async () => {
|
const registerToken = async () => {
|
||||||
try {
|
try {
|
||||||
|
await AsyncStorage.setItem('@notification_permission', "true");
|
||||||
const token = await getToken();
|
const token = await getToken();
|
||||||
if (token) {
|
if (token) {
|
||||||
await apiRegisteredToken({ user: entities.id, token });
|
await apiRegisteredToken({ user: entities.id, token, category: "register" });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Error registering token:', error);
|
console.warn('Error registering token:', error);
|
||||||
@@ -42,9 +46,10 @@ export default function ListSetting() {
|
|||||||
|
|
||||||
const unregisterToken = async () => {
|
const unregisterToken = async () => {
|
||||||
try {
|
try {
|
||||||
|
await AsyncStorage.setItem('@notification_permission', "false");
|
||||||
const token = await getToken();
|
const token = await getToken();
|
||||||
if (token) {
|
if (token) {
|
||||||
await apiUnregisteredToken({ user: entities.id, token });
|
await apiUnregisteredToken({ user: entities.id, token, category: "unregister" });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Error unregistering token:', error);
|
console.warn('Error unregistering token:', error);
|
||||||
@@ -52,15 +57,31 @@ export default function ListSetting() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const checkNotif = useCallback(async () => {
|
const checkNotif = useCallback(async () => {
|
||||||
const status = await checkPermission();
|
const osPermission = await checkPermission();
|
||||||
setIsNotificationEnabled((prev) => {
|
|
||||||
if (prev === false && status === true) {
|
// Jika dari tidak diijinkan sistem kemudian diijinkan (setelah balik dari pengaturan device)
|
||||||
registerToken();
|
if (prevOsPermission.current === false && osPermission === true) {
|
||||||
} else if (prev === true && status === false) {
|
await registerToken();
|
||||||
unregisterToken();
|
}
|
||||||
|
prevOsPermission.current = osPermission;
|
||||||
|
|
||||||
|
if (!osPermission) {
|
||||||
|
setIsNotificationEnabled(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = await getToken();
|
||||||
|
if (token) {
|
||||||
|
const response = await apiGetCheckToken({ user: entities.id, token });
|
||||||
|
setIsNotificationEnabled(!!response.data);
|
||||||
|
} else {
|
||||||
|
setIsNotificationEnabled(false);
|
||||||
}
|
}
|
||||||
return !!status;
|
} catch (error) {
|
||||||
});
|
console.warn('Error checking token status:', error);
|
||||||
|
setIsNotificationEnabled(false);
|
||||||
|
}
|
||||||
}, [entities.id]);
|
}, [entities.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -78,10 +99,12 @@ export default function ListSetting() {
|
|||||||
}, [checkNotif]);
|
}, [checkNotif]);
|
||||||
|
|
||||||
const handleToggleNotif = async () => {
|
const handleToggleNotif = async () => {
|
||||||
if (isNotificationEnabled) {
|
const osPermission = await checkPermission();
|
||||||
|
|
||||||
|
if (!osPermission) {
|
||||||
setModalConfig({
|
setModalConfig({
|
||||||
title: "Matikan Notifikasi?",
|
title: "Aktifkan Notifikasi?",
|
||||||
message: "Anda akan diarahkan ke pengaturan sistem untuk mematikan notifikasi.",
|
message: "Izin notifikasi tidak diberikan. Buka pengaturan sistem untuk mengaktifkannya?",
|
||||||
confirmText: "Buka Pengaturan",
|
confirmText: "Buka Pengaturan",
|
||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
setModalVisible(false);
|
setModalVisible(false);
|
||||||
@@ -90,22 +113,15 @@ export default function ListSetting() {
|
|||||||
});
|
});
|
||||||
setModalVisible(true);
|
setModalVisible(true);
|
||||||
} else {
|
} else {
|
||||||
const granted = await requestPermission();
|
// OS Permission is granted, perform in-app toggle
|
||||||
if (granted) {
|
const targetState = !isNotificationEnabled;
|
||||||
setIsNotificationEnabled(true);
|
if (targetState) {
|
||||||
registerToken();
|
await registerToken();
|
||||||
} else {
|
} else {
|
||||||
setModalConfig({
|
await unregisterToken();
|
||||||
title: "Aktifkan Notifikasi?",
|
|
||||||
message: "Izin notifikasi tidak diberikan. Buka pengaturan sistem untuk mengaktifkannya?",
|
|
||||||
confirmText: "Buka Pengaturan",
|
|
||||||
onConfirm: () => {
|
|
||||||
setModalVisible(false);
|
|
||||||
openSettings();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setModalVisible(true);
|
|
||||||
}
|
}
|
||||||
|
// UI will be updated by checkNotif (triggered by state change or manually here)
|
||||||
|
setIsNotificationEnabled(targetState);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -181,6 +197,10 @@ export default function ListSetting() {
|
|||||||
<ThemeOption label="Gelap" value="dark" icon="moon-outline" />
|
<ThemeOption label="Gelap" value="dark" icon="moon-outline" />
|
||||||
<ThemeOption label="Sistem" value="system" icon="phone-portrait-outline" />
|
<ThemeOption label="Sistem" value="system" icon="phone-portrait-outline" />
|
||||||
</DrawerBottom>
|
</DrawerBottom>
|
||||||
|
|
||||||
|
<Text style={{ color: colors.icon, textAlign: 'center', marginTop: 'auto', fontSize: 12 }}>
|
||||||
|
Versi {Constants.expoConfig?.version}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
import AuthProvider from '@/providers/AuthProvider';
|
import AuthProvider from '@/providers/AuthProvider';
|
||||||
import ThemeProvider, { useTheme } from '@/providers/ThemeProvider';
|
import ThemeProvider, { useTheme } from '@/providers/ThemeProvider';
|
||||||
|
import QueryProvider from '@/providers/QueryProvider';
|
||||||
|
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||||
|
import { flushErrorQueue } from '@/lib/errorLogger';
|
||||||
import { useFonts } from 'expo-font';
|
import { useFonts } from 'expo-font';
|
||||||
import { Stack } from 'expo-router';
|
import { Stack } from 'expo-router';
|
||||||
import * as SplashScreen from 'expo-splash-screen';
|
import * as SplashScreen from 'expo-splash-screen';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
import { AppState } from 'react-native';
|
||||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
import { NotifierWrapper } from 'react-native-notifier';
|
import { NotifierWrapper } from 'react-native-notifier';
|
||||||
import 'react-native-reanimated';
|
import 'react-native-reanimated';
|
||||||
@@ -21,7 +25,6 @@ function AppStack() {
|
|||||||
<>
|
<>
|
||||||
<Stack screenOptions={{ contentStyle: { backgroundColor: colors.header } }}>
|
<Stack screenOptions={{ contentStyle: { backgroundColor: colors.header } }}>
|
||||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="verification" options={{ headerShown: false }} />
|
|
||||||
<Stack.Screen name="(application)" options={{ headerShown: false }} />
|
<Stack.Screen name="(application)" options={{ headerShown: false }} />
|
||||||
</Stack>
|
</Stack>
|
||||||
<StatusBar style="auto" />
|
<StatusBar style="auto" />
|
||||||
@@ -37,22 +40,34 @@ export default function RootLayout() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loaded) {
|
if (loaded) {
|
||||||
SplashScreen.hideAsync();
|
SplashScreen.hideAsync();
|
||||||
|
flushErrorQueue();
|
||||||
}
|
}
|
||||||
}, [loaded]);
|
}, [loaded]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sub = AppState.addEventListener('change', (state) => {
|
||||||
|
if (state === 'active') flushErrorQueue();
|
||||||
|
});
|
||||||
|
return () => sub.remove();
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!loaded) {
|
if (!loaded) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={Styles.flex1}>
|
<GestureHandlerRootView style={Styles.flex1}>
|
||||||
<NotifierWrapper>
|
<ErrorBoundary>
|
||||||
<ThemeProvider>
|
<NotifierWrapper>
|
||||||
<AuthProvider>
|
<ThemeProvider>
|
||||||
<AppStack />
|
<QueryProvider>
|
||||||
</AuthProvider>
|
<AuthProvider>
|
||||||
</ThemeProvider>
|
<AppStack />
|
||||||
</NotifierWrapper>
|
</AuthProvider>
|
||||||
|
</QueryProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</NotifierWrapper>
|
||||||
|
</ErrorBoundary>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
import { ButtonForm } from "@/components/buttonForm";
|
|
||||||
import Text from '@/components/Text';
|
|
||||||
import { ConstEnv } from "@/constants/ConstEnv";
|
|
||||||
import Styles from "@/constants/Styles";
|
|
||||||
import { useAuthSession } from "@/providers/AuthProvider";
|
|
||||||
import { useTheme } from "@/providers/ThemeProvider";
|
|
||||||
import CryptoES from "crypto-es";
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import { Image, View } from "react-native";
|
|
||||||
import { CodeField, Cursor, useBlurOnFulfill, useClearByFocusCell, } from 'react-native-confirmation-code-field';
|
|
||||||
|
|
||||||
export default function Index() {
|
|
||||||
const [value, setValue] = useState('');
|
|
||||||
const ref = useBlurOnFulfill({ value, cellCount: 4 });
|
|
||||||
const [props, getCellOnLayoutHandler] = useClearByFocusCell({
|
|
||||||
value,
|
|
||||||
setValue,
|
|
||||||
});
|
|
||||||
const { colors } = useTheme();
|
|
||||||
|
|
||||||
const { signIn } = useAuthSession();
|
|
||||||
const login = (): void => {
|
|
||||||
// WARNING: This is a hardcoded bypass for development purposes.
|
|
||||||
// It should be removed or secured before production release.
|
|
||||||
if (__DEV__) {
|
|
||||||
const random: string = 'contohLoginMobileDarmasaba';
|
|
||||||
var mytexttoEncryption = "contohLoginMobileDarmasaba"
|
|
||||||
const encrypted = CryptoES.AES.encrypt(mytexttoEncryption, ConstEnv.pass_encrypt).toString();
|
|
||||||
signIn(encrypted);
|
|
||||||
} else {
|
|
||||||
console.warn("Bypass login disabled in production.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<View style={[Styles.wrapLogin, { backgroundColor: colors.background }]} >
|
|
||||||
<View style={[Styles.rowItemsCenter, Styles.mv50]}>
|
|
||||||
<Image
|
|
||||||
source={require("../assets/images/logo.png")}
|
|
||||||
style={[{ width: 300, height: 150 }]}
|
|
||||||
width={270}
|
|
||||||
height={110}
|
|
||||||
/>
|
|
||||||
{/* <Text style={[Styles.textSubtitle]}>PERBEKEL DARMASABA</Text> */}
|
|
||||||
</View>
|
|
||||||
<View style={[Styles.mb30]}>
|
|
||||||
<Text style={[Styles.textDefaultSemiBold]}>Verifikasi Nomor Telepon</Text>
|
|
||||||
<Text style={[Styles.textMediumNormal]}>Masukkan kode yang kami kirimkan melalui WhatsApp</Text>
|
|
||||||
<Text style={[Styles.textMediumSemiBold]}>+628980185458</Text>
|
|
||||||
</View>
|
|
||||||
<CodeField
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
value={value}
|
|
||||||
rootStyle={{ width: '80%', alignSelf: 'center' }}
|
|
||||||
onChangeText={setValue}
|
|
||||||
cellCount={4}
|
|
||||||
keyboardType="number-pad"
|
|
||||||
renderCell={({ index, symbol, isFocused }) => (
|
|
||||||
<Text
|
|
||||||
key={index}
|
|
||||||
style={[Styles.verificationCell, isFocused && Styles.verificationFocusCell, { borderColor: isFocused ? colors.tint : colors.icon, color: colors.text }]}
|
|
||||||
onLayout={getCellOnLayoutHandler(index)}>
|
|
||||||
{symbol || (isFocused ? <Cursor /> : null)}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<ButtonForm
|
|
||||||
text="SUBMIT"
|
|
||||||
// onPress={() => { router.push("/home") }}
|
|
||||||
onPress={login}
|
|
||||||
/>
|
|
||||||
<Text style={[Styles.textInformation, Styles.mt05, Styles.cDefault, { textAlign: 'center', color: colors.tint }]}>
|
|
||||||
Tidak Menerima kode verifikasi? Kirim Ulang
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
79
components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import React, { Component, ReactNode } from 'react';
|
||||||
|
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class ErrorBoundary extends Component<Props, State> {
|
||||||
|
state: State = { hasError: false, error: null };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error): void {
|
||||||
|
const { logError } = require('@/lib/errorLogger');
|
||||||
|
logError(error.message, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleReset = () => {
|
||||||
|
this.setState({ hasError: false, error: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
if (this.props.fallback) return this.props.fallback;
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.title}>Terjadi Kesalahan</Text>
|
||||||
|
<Text style={styles.message}>
|
||||||
|
Silahkan coba lagi beberapa saat lagi atau hubungi admin untuk bantuan.
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity style={styles.button} onPress={this.handleReset}>
|
||||||
|
<Text style={styles.buttonText}>Coba Lagi</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 24,
|
||||||
|
backgroundColor: '#f7f7f7',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 8,
|
||||||
|
color: '#11181C',
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#707887',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
backgroundColor: '#19345E',
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
42
components/ErrorView.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import Text from '@/components/Text';
|
||||||
|
import Styles from '@/constants/Styles';
|
||||||
|
import { useTheme } from '@/providers/ThemeProvider';
|
||||||
|
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
|
import React from 'react';
|
||||||
|
import { View } from 'react-native';
|
||||||
|
|
||||||
|
interface ErrorViewProps {
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
icon?: keyof typeof MaterialCommunityIcons.glyphMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ErrorView component to display error or empty states.
|
||||||
|
* Used when data is not found, deleted, or an error occurs during fetching.
|
||||||
|
*/
|
||||||
|
export default function ErrorView({
|
||||||
|
title = "Terjadi Kesalahan",
|
||||||
|
message = "Data tidak ditemukan atau sudah dihapus.",
|
||||||
|
icon = "alert-circle-outline"
|
||||||
|
}: ErrorViewProps) {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[Styles.flex1, Styles.contentItemCenter, Styles.ph20]}>
|
||||||
|
<View style={[Styles.mb10]}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={icon}
|
||||||
|
size={40}
|
||||||
|
color={colors.dimmed}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={[Styles.textDefaultSemiBold, { color: colors.text, textAlign: 'center' }]}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text style={[Styles.textMediumNormal, { color: colors.dimmed, textAlign: 'center', marginTop: 4 }]}>
|
||||||
|
{message}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@ export default function ViewLogin({ onValidate }: Props) {
|
|||||||
} else {
|
} else {
|
||||||
return Toast.show({ type: 'small', text1: response.message, position: 'bottom' })
|
return Toast.show({ type: 'small', text1: response.message, position: 'bottom' })
|
||||||
}
|
}
|
||||||
} catch (error : any ) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
const message = error?.response?.data?.message || "Gagal login"
|
const message = error?.response?.data?.message || "Gagal login"
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export default function HeaderRightDivisionList() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{
|
{
|
||||||
(entityUser.role == "userRole" || entityUser.role == "developer") &&
|
(entityUser.role == "supadmin" || entityUser.role == "developer") &&
|
||||||
<MenuItemRow
|
<MenuItemRow
|
||||||
icon={<AntDesign name="filter" color={colors.text} size={25} />}
|
icon={<AntDesign name="filter" color={colors.text} size={25} />}
|
||||||
title="Filter"
|
title="Filter"
|
||||||
|
|||||||
@@ -2,44 +2,54 @@ import Styles from "@/constants/Styles";
|
|||||||
import { Dimensions, View } from "react-native";
|
import { Dimensions, View } from "react-native";
|
||||||
import { BarChart } from "react-native-gifted-charts";
|
import { BarChart } from "react-native-gifted-charts";
|
||||||
import { useTheme } from "@/providers/ThemeProvider";
|
import { useTheme } from "@/providers/ThemeProvider";
|
||||||
|
import { useMemo } from "react";
|
||||||
import Text from "../Text";
|
import Text from "../Text";
|
||||||
|
|
||||||
export default function ReportChartDocument({ data }: { data: { label: string; value: number; }[] }) {
|
export default function ReportChartDocument({ data }: { data: { label: string; value: number; }[] }) {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const maxValue = Math.max(...data.map(i => i.value))
|
|
||||||
const barData = [
|
|
||||||
{ value: 23, label: 'Gambar', },
|
|
||||||
{ value: 12, label: 'Dokumen' },
|
|
||||||
];
|
|
||||||
const width = Dimensions.get("window").width;
|
const width = Dimensions.get("window").width;
|
||||||
|
|
||||||
|
const maxValue = useMemo(() => {
|
||||||
|
const maxVal = data.reduce((max: number, obj: { value: number; }) => Math.max(max, obj.value), 0);
|
||||||
|
if (maxVal === 0) return 10;
|
||||||
|
if (maxVal < 5) return 5;
|
||||||
|
return Math.ceil(maxVal / 10) * 10;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const barData = useMemo(() => {
|
||||||
|
return data.map(item => ({
|
||||||
|
...item,
|
||||||
|
frontColor: item.value > 0 ? "#fac858" : "transparent",
|
||||||
|
topLabelComponent: () => (
|
||||||
|
<View style={{ marginBottom: 5 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: width * 0.25
|
||||||
|
}}>
|
||||||
|
<Text style={{ color: colors.text, fontSize: 12 }}>{item.value > 0 ? item.value : ""}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
}, [data, colors.text]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[Styles.wrapPaper, Styles.contentItemCenter, Styles.mb15, { backgroundColor: colors.card, borderColor: colors.background }]}>
|
<View style={[Styles.wrapPaper, Styles.contentItemCenter, Styles.mb15, { backgroundColor: colors.card, borderColor: colors.background }]}>
|
||||||
<Text style={[Styles.textSubtitle, Styles.mv15]}>JUMLAH DOKUMEN</Text>
|
<Text style={[Styles.textSubtitle, Styles.mv15]}>JUMLAH DOKUMEN</Text>
|
||||||
<BarChart
|
<BarChart
|
||||||
|
key={JSON.stringify(data)}
|
||||||
xAxisLabelTextStyle={{ color: colors.text }}
|
xAxisLabelTextStyle={{ color: colors.text }}
|
||||||
yAxisTextStyle={{ color: colors.text }}
|
yAxisTextStyle={{ color: colors.text }}
|
||||||
showFractionalValues={false}
|
showFractionalValues={false}
|
||||||
showYAxisIndices
|
showYAxisIndices
|
||||||
noOfSections={maxValue < 5 ? 2 : 4}
|
noOfSections={maxValue < 5 ? (maxValue === 0 ? 4 : maxValue) : 4}
|
||||||
maxValue={maxValue}
|
maxValue={maxValue}
|
||||||
data={data}
|
data={barData}
|
||||||
isAnimated
|
isAnimated
|
||||||
width={width - 140}
|
width={width - 140}
|
||||||
barWidth={width * 0.25}
|
barWidth={width * 0.25}
|
||||||
frontColor="#fac858"
|
|
||||||
renderTooltip={(item: any, index: any) => {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
width: width * 0.25
|
|
||||||
}}>
|
|
||||||
<Text style={{ color: colors.text }}>{item.value}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,44 +2,54 @@ import Styles from "@/constants/Styles";
|
|||||||
import { Dimensions, View } from "react-native";
|
import { Dimensions, View } from "react-native";
|
||||||
import { BarChart } from "react-native-gifted-charts";
|
import { BarChart } from "react-native-gifted-charts";
|
||||||
import { useTheme } from "@/providers/ThemeProvider";
|
import { useTheme } from "@/providers/ThemeProvider";
|
||||||
|
import { useMemo } from "react";
|
||||||
import Text from "../Text";
|
import Text from "../Text";
|
||||||
|
|
||||||
export default function ReportChartEvent({ data }: { data: { label: string; value: number; }[] }) {
|
export default function ReportChartEvent({ data }: { data: { label: string; value: number; }[] }) {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const width = Dimensions.get("window").width;
|
const width = Dimensions.get("window").width;
|
||||||
const maxValue = Math.max(...data.map(i => i.value))
|
|
||||||
const barData = [
|
const maxValue = useMemo(() => {
|
||||||
{ value: 23, label: 'Akan Datang', },
|
const maxVal = data.reduce((max: number, obj: { value: number; }) => Math.max(max, obj.value), 0);
|
||||||
{ value: 12, label: 'Selesai' },
|
if (maxVal === 0) return 10;
|
||||||
];
|
if (maxVal < 5) return 5;
|
||||||
|
return Math.ceil(maxVal / 10) * 10;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const barData = useMemo(() => {
|
||||||
|
return data.map(item => ({
|
||||||
|
...item,
|
||||||
|
frontColor: item.value > 0 ? "#177AD5" : "transparent",
|
||||||
|
topLabelComponent: () => (
|
||||||
|
<View style={{ marginBottom: 5 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: width * 0.25
|
||||||
|
}}>
|
||||||
|
<Text style={{ color: colors.text, fontSize: 12 }}>{item.value > 0 ? item.value : ""}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
}, [data, colors.text]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[Styles.wrapPaper, Styles.contentItemCenter, Styles.mb15, { backgroundColor: colors.card, borderColor: colors.background }]}>
|
<View style={[Styles.wrapPaper, Styles.contentItemCenter, Styles.mb15, { backgroundColor: colors.card, borderColor: colors.background }]}>
|
||||||
<Text style={[Styles.textSubtitle, Styles.mv15]}>ACARA DIVISI</Text>
|
<Text style={[Styles.textSubtitle, Styles.mv15]}>ACARA DIVISI</Text>
|
||||||
<BarChart
|
<BarChart
|
||||||
|
key={JSON.stringify(data)}
|
||||||
xAxisLabelTextStyle={{ color: colors.text }}
|
xAxisLabelTextStyle={{ color: colors.text }}
|
||||||
yAxisTextStyle={{ color: colors.text }}
|
yAxisTextStyle={{ color: colors.text }}
|
||||||
showFractionalValues={false}
|
showFractionalValues={false}
|
||||||
showYAxisIndices
|
showYAxisIndices
|
||||||
noOfSections={maxValue < 5 ? 2 : 4}
|
noOfSections={maxValue < 5 ? (maxValue === 0 ? 4 : maxValue) : 4}
|
||||||
maxValue={maxValue}
|
maxValue={maxValue}
|
||||||
frontColor="#177AD5"
|
data={barData}
|
||||||
data={data}
|
|
||||||
isAnimated
|
isAnimated
|
||||||
width={width - 140}
|
width={width - 140}
|
||||||
barWidth={width * 0.25}
|
barWidth={width * 0.25}
|
||||||
renderTooltip={(item: any, index: any) => {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
width: width * 0.25
|
|
||||||
}}>
|
|
||||||
<Text style={{ color: colors.text }}>{item.value}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { setEntities } from "@/lib/bannerSlice";
|
|||||||
import { setEntityUser } from "@/lib/userSlice";
|
import { setEntityUser } from "@/lib/userSlice";
|
||||||
import { useAuthSession } from "@/providers/AuthProvider";
|
import { useAuthSession } from "@/providers/AuthProvider";
|
||||||
import { useTheme } from "@/providers/ThemeProvider";
|
import { useTheme } from "@/providers/ThemeProvider";
|
||||||
import { AntDesign, Feather, FontAwesome5, Ionicons, MaterialCommunityIcons, MaterialIcons, } from "@expo/vector-icons";
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { Dimensions, Image, View } from "react-native";
|
import { Dimensions, Image, View } from "react-native";
|
||||||
@@ -23,37 +24,44 @@ export default function CaraouselHome2({ refreshing }: { refreshing: boolean })
|
|||||||
const progress = useSharedValue<number>(0);
|
const progress = useSharedValue<number>(0);
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
const entities = useSelector((state: any) => state.banner)
|
const entities = useSelector((state: any) => state.banner)
|
||||||
const entityUser = useSelector((state: any) => state.user)
|
|
||||||
|
|
||||||
async function handleBannerView() {
|
// Query for Banners
|
||||||
const hasil = await decryptToken(String(token?.current))
|
const { data: banners } = useQuery({
|
||||||
apiGetBanner({ user: hasil }).then((data) => {
|
queryKey: ['banners'],
|
||||||
if (data.data.length > 0) {
|
queryFn: async () => {
|
||||||
dispatch(setEntities(data.data))
|
const hasil = await decryptToken(String(token?.current))
|
||||||
} else {
|
const data = await apiGetBanner({ user: hasil })
|
||||||
dispatch(setEntities([]))
|
return data.data || []
|
||||||
}
|
},
|
||||||
})
|
enabled: !!token?.current,
|
||||||
}
|
staleTime: 0,
|
||||||
|
})
|
||||||
|
|
||||||
async function handleUser() {
|
// Query for Profile (Role Check)
|
||||||
const hasil = await decryptToken(String(token?.current))
|
const { data: profile } = useQuery({
|
||||||
const response = await apiGetProfile({ id: hasil })
|
queryKey: ['profile'], // Shares same key as Home.tsx
|
||||||
dispatch(setEntityUser({ role: response.data.idUserRole, admin: false }))
|
queryFn: async () => {
|
||||||
}
|
const hasil = await decryptToken(String(token?.current))
|
||||||
|
const data = await apiGetProfile({ id: hasil })
|
||||||
|
return data.data
|
||||||
|
},
|
||||||
|
enabled: !!token?.current,
|
||||||
|
staleTime: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sync Banners to Redux
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (refreshing)
|
if (banners) {
|
||||||
handleBannerView()
|
dispatch(setEntities(banners))
|
||||||
}, [refreshing]);
|
}
|
||||||
|
}, [banners, dispatch])
|
||||||
|
|
||||||
|
// Sync User Role to Redux
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handleBannerView()
|
if (profile) {
|
||||||
}, [dispatch]);
|
dispatch(setEntityUser({ role: profile.idUserRole, admin: false }))
|
||||||
|
}
|
||||||
useEffect(() => {
|
}, [profile, dispatch])
|
||||||
handleUser()
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -92,7 +100,7 @@ export default function CaraouselHome2({ refreshing }: { refreshing: boolean })
|
|||||||
backgroundColor: colors.primary
|
backgroundColor: colors.primary
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
resizeMode="cover"
|
resizeMode="stretch"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import Styles from "@/constants/Styles";
|
|||||||
import { apiGetDataHome } from "@/lib/api";
|
import { apiGetDataHome } from "@/lib/api";
|
||||||
import { useAuthSession } from "@/providers/AuthProvider";
|
import { useAuthSession } from "@/providers/AuthProvider";
|
||||||
import { useTheme } from "@/providers/ThemeProvider";
|
import { useTheme } from "@/providers/ThemeProvider";
|
||||||
import { useEffect, useState } from "react";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useMemo } from "react";
|
||||||
import { Dimensions, View } from "react-native";
|
import { Dimensions, View } from "react-native";
|
||||||
import { BarChart } from "react-native-gifted-charts";
|
import { BarChart } from "react-native-gifted-charts";
|
||||||
import Skeleton from "../skeleton";
|
import Skeleton from "../skeleton";
|
||||||
@@ -15,80 +16,69 @@ type Props = {
|
|||||||
}[]
|
}[]
|
||||||
|
|
||||||
export default function ChartDokumenHome({ refreshing }: { refreshing: boolean }) {
|
export default function ChartDokumenHome({ refreshing }: { refreshing: boolean }) {
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const { decryptToken, token } = useAuthSession()
|
const { decryptToken, token } = useAuthSession()
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const [data, setData] = useState<Props>([])
|
|
||||||
const [maxValue, setMaxValue] = useState(5)
|
|
||||||
const barData = [
|
|
||||||
{ value: 23, label: 'Gambar', frontColor: '#fac858' },
|
|
||||||
{ value: 12, label: 'Dokumen', frontColor: '#92cc76' },
|
|
||||||
];
|
|
||||||
const width = Dimensions.get("window").width;
|
const width = Dimensions.get("window").width;
|
||||||
|
|
||||||
|
// TanStack Query for Document Chart data
|
||||||
async function handleData(loading: boolean) {
|
const { data: chartData = [], isLoading, isFetching } = useQuery({
|
||||||
try {
|
queryKey: ['homeData', 'dokumen'],
|
||||||
setLoading(loading)
|
queryFn: async () => {
|
||||||
const hasil = await decryptToken(String(token?.current))
|
const hasil = await decryptToken(String(token?.current))
|
||||||
const response = await apiGetDataHome({ cat: "dokumen", user: hasil })
|
const response = await apiGetDataHome({ cat: "dokumen", user: hasil })
|
||||||
const maxValue = response.data.reduce((max: number, obj: { value: number; }) => Math.max(max, obj.value), -Infinity);
|
return response.data.map((item: { color: any; label: any; value: any; }) => {
|
||||||
const roundUp = Math.ceil(maxValue / 10) * 10
|
const val = Number(item.value) || 0;
|
||||||
setMaxValue(roundUp)
|
return {
|
||||||
const convertedArray = response.data.map((item: { color: any; label: any; value: any; }) => ({
|
frontColor: val > 0 ? (item.color || '#fac858') : 'transparent',
|
||||||
frontColor: item.color,
|
label: item.label,
|
||||||
label: item.label,
|
value: val,
|
||||||
value: Number(item.value)
|
}
|
||||||
}));
|
}) as Props
|
||||||
setData(convertedArray)
|
},
|
||||||
} catch (error) {
|
enabled: !!token?.current,
|
||||||
console.error(error)
|
staleTime: 0,
|
||||||
} finally {
|
})
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
// Derived state for maxValue
|
||||||
if (refreshing)
|
const maxValue = useMemo(() => {
|
||||||
handleData(false)
|
const maxVal = chartData.reduce((max: number, obj: { value: number; }) => Math.max(max, obj.value), 0);
|
||||||
}, [refreshing]);
|
// Adjust maxValue and intervals based on the data
|
||||||
|
if (maxVal === 0) return 10;
|
||||||
useEffect(() => {
|
if (maxVal < 5) return 5;
|
||||||
handleData(true)
|
return Math.ceil(maxVal / 10) * 10;
|
||||||
}, []);
|
}, [chartData]);
|
||||||
|
|
||||||
|
const barData = useMemo(() => {
|
||||||
|
return chartData.map(item => ({
|
||||||
|
...item,
|
||||||
|
topLabelComponent: () => (
|
||||||
|
<View style={{ marginBottom: 5 }}>
|
||||||
|
<Text style={{ color: colors.text, fontSize: 12 }}>{item.value > 0 ? item.value : ""}</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
}, [chartData, colors.text]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[Styles.wrapPaper, Styles.contentItemCenter, Styles.mb15, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
|
<View style={[Styles.wrapPaper, Styles.contentItemCenter, Styles.mb15, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
|
||||||
<Text style={[Styles.textSubtitle, Styles.mv15]}>JUMLAH DOKUMEN</Text>
|
<Text style={[Styles.textSubtitle, Styles.mv15]}>JUMLAH DOKUMEN</Text>
|
||||||
{
|
{
|
||||||
loading ? <Skeleton width={100} height={200} borderRadius={10} widthType="percent" />
|
isLoading || (refreshing && isFetching) ? <Skeleton width={100} height={200} borderRadius={10} widthType="percent" />
|
||||||
:
|
:
|
||||||
<BarChart
|
<BarChart
|
||||||
|
key={JSON.stringify(chartData)}
|
||||||
showFractionalValues={false}
|
showFractionalValues={false}
|
||||||
showYAxisIndices
|
showYAxisIndices
|
||||||
noOfSections={4}
|
noOfSections={maxValue < 5 ? (maxValue === 0 ? 4 : maxValue) : 4}
|
||||||
maxValue={maxValue}
|
maxValue={maxValue}
|
||||||
data={data}
|
data={barData}
|
||||||
isAnimated
|
isAnimated
|
||||||
width={width - 140}
|
width={width - 140}
|
||||||
barWidth={width * 0.25}
|
barWidth={width * 0.25}
|
||||||
yAxisTextStyle={{ color: colors.text }}
|
yAxisTextStyle={{ color: colors.text }}
|
||||||
xAxisLabelTextStyle={{ color: colors.text }}
|
xAxisLabelTextStyle={{ color: colors.text }}
|
||||||
renderTooltip={(item: any, index: any) => {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
width: width * 0.25
|
|
||||||
}}>
|
|
||||||
<Text>{item.value}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@ import Styles from "@/constants/Styles";
|
|||||||
import { apiGetDataHome } from "@/lib/api";
|
import { apiGetDataHome } from "@/lib/api";
|
||||||
import { useAuthSession } from "@/providers/AuthProvider";
|
import { useAuthSession } from "@/providers/AuthProvider";
|
||||||
import { useTheme } from "@/providers/ThemeProvider";
|
import { useTheme } from "@/providers/ThemeProvider";
|
||||||
import { useEffect, useState } from "react";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { PieChart } from "react-native-gifted-charts";
|
import { PieChart } from "react-native-gifted-charts";
|
||||||
import Skeleton from "../skeleton";
|
import Skeleton from "../skeleton";
|
||||||
@@ -17,45 +17,32 @@ type Props = {
|
|||||||
export default function ChartProgresHome({ refreshing }: { refreshing: boolean }) {
|
export default function ChartProgresHome({ refreshing }: { refreshing: boolean }) {
|
||||||
const { decryptToken, token } = useAuthSession()
|
const { decryptToken, token } = useAuthSession()
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const [data, setData] = useState<Props>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
|
|
||||||
async function handleData(loading: boolean) {
|
// TanStack Query for Progress Chart data
|
||||||
try {
|
const { data: chartData = [], isLoading } = useQuery({
|
||||||
setLoading(loading)
|
queryKey: ['homeData', 'progress'],
|
||||||
|
queryFn: async () => {
|
||||||
const hasil = await decryptToken(String(token?.current))
|
const hasil = await decryptToken(String(token?.current))
|
||||||
const response = await apiGetDataHome({ cat: "progress", user: hasil })
|
const response = await apiGetDataHome({ cat: "progress", user: hasil })
|
||||||
const convertedArray = response.data.map((item: { color: any; text: any; value: any; }) => ({
|
return response.data.map((item: { color: any; text: any; value: any; }) => ({
|
||||||
color: item.color,
|
color: item.color,
|
||||||
text: item.text,
|
text: item.text,
|
||||||
value: Number(item.value)
|
value: Number(item.value)
|
||||||
}));
|
})) as Props
|
||||||
setData(convertedArray)
|
},
|
||||||
} catch (error) {
|
enabled: !!token?.current,
|
||||||
console.error(error)
|
staleTime: 0,
|
||||||
} finally {
|
})
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (refreshing)
|
|
||||||
handleData(false)
|
|
||||||
}, [refreshing]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
handleData(true)
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[Styles.wrapPaper, Styles.contentItemCenter, Styles.mb15, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
|
<View style={[Styles.wrapPaper, Styles.contentItemCenter, Styles.mb15, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
|
||||||
<Text style={[Styles.textSubtitle, Styles.mv15]}>PROGRES KEGIATAN</Text>
|
<Text style={[Styles.textSubtitle, Styles.mv15]}>PROGRES KEGIATAN</Text>
|
||||||
{
|
{
|
||||||
loading ? <Skeleton width={100} height={200} borderRadius={10} widthType="percent" />
|
isLoading ? <Skeleton width={100} height={200} borderRadius={10} widthType="percent" />
|
||||||
:
|
:
|
||||||
<>
|
<>
|
||||||
<PieChart
|
<PieChart
|
||||||
data={data}
|
data={chartData}
|
||||||
showText
|
showText
|
||||||
showValuesAsTooltipText
|
showValuesAsTooltipText
|
||||||
textColor={'black'}
|
textColor={'black'}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import Styles from "@/constants/Styles";
|
import Styles from "@/constants/Styles";
|
||||||
import { apiGetDataHome } from "@/lib/api";
|
import { apiGetDataHome } from "@/lib/api";
|
||||||
import { useAuthSession } from "@/providers/AuthProvider";
|
import { useAuthSession } from "@/providers/AuthProvider";
|
||||||
|
import { useTheme } from "@/providers/ThemeProvider";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import DiscussionItem from "../discussionItem";
|
import DiscussionItem from "../discussionItem";
|
||||||
import Skeleton from "../skeleton";
|
import Skeleton from "../skeleton";
|
||||||
import Text from "../Text";
|
import Text from "../Text";
|
||||||
import { useTheme } from "@/providers/ThemeProvider";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
id: string
|
id: string
|
||||||
@@ -20,46 +20,33 @@ type Props = {
|
|||||||
|
|
||||||
export default function DisccussionHome({ refreshing }: { refreshing: boolean }) {
|
export default function DisccussionHome({ refreshing }: { refreshing: boolean }) {
|
||||||
const { decryptToken, token } = useAuthSession()
|
const { decryptToken, token } = useAuthSession()
|
||||||
const [data, setData] = useState<Props[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
|
|
||||||
|
// TanStack Query for Discussion data
|
||||||
async function handleData(loading: boolean) {
|
const { data: homeDiscussions = [], isLoading } = useQuery({
|
||||||
try {
|
queryKey: ['homeData', 'discussion'],
|
||||||
setLoading(loading)
|
queryFn: async () => {
|
||||||
const hasil = await decryptToken(String(token?.current))
|
const hasil = await decryptToken(String(token?.current))
|
||||||
const response = await apiGetDataHome({ cat: "discussion", user: hasil })
|
const response = await apiGetDataHome({ cat: "discussion", user: hasil })
|
||||||
setData(response.data)
|
return response.data as Props[]
|
||||||
} catch (error) {
|
},
|
||||||
console.error(error)
|
enabled: !!token?.current,
|
||||||
} finally {
|
staleTime: 0,
|
||||||
setLoading(false)
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (refreshing)
|
|
||||||
handleData(false)
|
|
||||||
}, [refreshing]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
handleData(true)
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[Styles.mb15]}>
|
<View style={[Styles.mb15]}>
|
||||||
<Text style={[Styles.textDefaultSemiBold, Styles.mv10]}>Diskusi</Text>
|
<Text style={[Styles.textDefaultSemiBold, Styles.mv10]}>Diskusi</Text>
|
||||||
<View style={[Styles.wrapPaper, { backgroundColor: colors.card, borderColor: colors.icon + '20' }, Styles.p0]}>
|
<View style={[Styles.wrapPaper, { backgroundColor: colors.card, borderColor: colors.icon + '20' }, Styles.p0]}>
|
||||||
{
|
{
|
||||||
loading ?
|
isLoading ?
|
||||||
<>
|
<>
|
||||||
<Skeleton width={100} height={70} borderRadius={10} widthType="percent" />
|
<Skeleton width={100} height={70} borderRadius={10} widthType="percent" />
|
||||||
<Skeleton width={100} height={70} borderRadius={10} widthType="percent" />
|
<Skeleton width={100} height={70} borderRadius={10} widthType="percent" />
|
||||||
</>
|
</>
|
||||||
:
|
:
|
||||||
data.length > 0 ?
|
homeDiscussions.length > 0 ?
|
||||||
data.map((item, index) => {
|
homeDiscussions.map((item: Props, index: number) => {
|
||||||
return (
|
return (
|
||||||
<DiscussionItem key={index} title={item.desc} user={item.user} date={item.date} onPress={() => { router.push(`/division/${item.idDivision}/discussion/${item.id}`) }} />
|
<DiscussionItem key={index} title={item.desc} user={item.user} date={item.date} onPress={() => { router.push(`/division/${item.idDivision}/discussion/${item.id}`) }} />
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { apiGetDataHome } from "@/lib/api";
|
|||||||
import { useAuthSession } from "@/providers/AuthProvider";
|
import { useAuthSession } from "@/providers/AuthProvider";
|
||||||
import { useTheme } from "@/providers/ThemeProvider";
|
import { useTheme } from "@/providers/ThemeProvider";
|
||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import React, { useEffect, useState } from "react";
|
import React from "react";
|
||||||
import { Dimensions, Pressable, View } from "react-native";
|
import { Dimensions, Pressable, View } from "react-native";
|
||||||
import { ICarouselInstance } from "react-native-reanimated-carousel";
|
import { ICarouselInstance } from "react-native-reanimated-carousel";
|
||||||
import Skeleton from "../skeleton";
|
import Skeleton from "../skeleton";
|
||||||
@@ -21,45 +22,31 @@ export default function DivisionHome({ refreshing }: { refreshing: boolean }) {
|
|||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const ref = React.useRef<ICarouselInstance>(null)
|
const ref = React.useRef<ICarouselInstance>(null)
|
||||||
const width = Dimensions.get("window").width
|
const width = Dimensions.get("window").width
|
||||||
const [data, setData] = useState<Props[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const arrSkeleton = Array.from({ length: 2 }, (_, index) => index)
|
const arrSkeleton = Array.from({ length: 2 }, (_, index) => index)
|
||||||
|
|
||||||
async function handleData(loading: boolean) {
|
// TanStack Query for Division data
|
||||||
try {
|
const { data: homeDivisions = [], isLoading } = useQuery({
|
||||||
setLoading(loading)
|
queryKey: ['homeData', 'division'],
|
||||||
|
queryFn: async () => {
|
||||||
const hasil = await decryptToken(String(token?.current))
|
const hasil = await decryptToken(String(token?.current))
|
||||||
const response = await apiGetDataHome({ cat: "division", user: hasil })
|
const response = await apiGetDataHome({ cat: "division", user: hasil })
|
||||||
setData(response.data)
|
return response.data as Props[]
|
||||||
} catch (error) {
|
},
|
||||||
console.error(error)
|
enabled: !!token?.current,
|
||||||
} finally {
|
staleTime: 0,
|
||||||
setLoading(false)
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (refreshing)
|
|
||||||
handleData(false)
|
|
||||||
}, [refreshing]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
handleData(true)
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[Styles.mb15]}>
|
<View style={[Styles.mb15]}>
|
||||||
<Text style={[Styles.textDefaultSemiBold, Styles.mb10]}>Divisi Teraktif</Text>
|
<Text style={[Styles.textDefaultSemiBold, Styles.mb10]}>Divisi Teraktif</Text>
|
||||||
{
|
{
|
||||||
loading ?
|
isLoading ?
|
||||||
arrSkeleton.map((item, index) => (
|
arrSkeleton.map((item, index) => (
|
||||||
<Skeleton key={index} width={100} height={80} borderRadius={10} widthType="percent" />
|
<Skeleton key={index} width={100} height={80} borderRadius={10} widthType="percent" />
|
||||||
))
|
))
|
||||||
:
|
:
|
||||||
data.length > 0 ?
|
homeDivisions.length > 0 ?
|
||||||
data.map((item, index) => (
|
homeDivisions.map((item, index) => (
|
||||||
<Pressable style={[Styles.wrapPaper, Styles.mb05, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]} key={index} onPress={() => { router.push(`/division/${item.id}`) }}>
|
<Pressable style={[Styles.wrapPaper, Styles.mb05, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]} key={index} onPress={() => { router.push(`/division/${item.id}`) }}>
|
||||||
<View style={[Styles.rowSpaceBetween, { alignItems: 'center' }]}>
|
<View style={[Styles.rowSpaceBetween, { alignItems: 'center' }]}>
|
||||||
<View>
|
<View>
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import Styles from "@/constants/Styles";
|
|||||||
import { apiGetDataHome } from "@/lib/api";
|
import { apiGetDataHome } from "@/lib/api";
|
||||||
import { useAuthSession } from "@/providers/AuthProvider";
|
import { useAuthSession } from "@/providers/AuthProvider";
|
||||||
import { useTheme } from "@/providers/ThemeProvider";
|
import { useTheme } from "@/providers/ThemeProvider";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import EventItem from "../eventItem";
|
import EventItem from "../eventItem";
|
||||||
import Skeleton from "../skeleton";
|
import Skeleton from "../skeleton";
|
||||||
@@ -26,30 +26,18 @@ type Props = {
|
|||||||
export default function EventHome({ refreshing }: { refreshing: boolean }) {
|
export default function EventHome({ refreshing }: { refreshing: boolean }) {
|
||||||
const { decryptToken, token } = useAuthSession()
|
const { decryptToken, token } = useAuthSession()
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const [data, setData] = useState<Props[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
|
|
||||||
async function handleData(loading: boolean) {
|
// TanStack Query for Event data
|
||||||
try {
|
const { data: homeEvents = [], isLoading } = useQuery({
|
||||||
setLoading(loading)
|
queryKey: ['homeData', 'event'],
|
||||||
|
queryFn: async () => {
|
||||||
const hasil = await decryptToken(String(token?.current))
|
const hasil = await decryptToken(String(token?.current))
|
||||||
const response = await apiGetDataHome({ cat: "event", user: hasil })
|
const response = await apiGetDataHome({ cat: "event", user: hasil })
|
||||||
setData(response.data)
|
return response.data as Props[]
|
||||||
} catch (error) {
|
},
|
||||||
console.error(error)
|
enabled: !!token?.current,
|
||||||
} finally {
|
staleTime: 0,
|
||||||
setLoading(false)
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (refreshing)
|
|
||||||
handleData(false)
|
|
||||||
}, [refreshing]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
handleData(true)
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[Styles.mb15]}>
|
<View style={[Styles.mb15]}>
|
||||||
@@ -57,14 +45,14 @@ export default function EventHome({ refreshing }: { refreshing: boolean }) {
|
|||||||
<View style={[Styles.wrapPaper, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
|
<View style={[Styles.wrapPaper, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
|
||||||
|
|
||||||
{
|
{
|
||||||
loading ?
|
isLoading ?
|
||||||
<>
|
<>
|
||||||
<Skeleton width={100} height={70} borderRadius={10} widthType="percent" />
|
<Skeleton width={100} height={70} borderRadius={10} widthType="percent" />
|
||||||
<Skeleton width={100} height={70} borderRadius={10} widthType="percent" />
|
<Skeleton width={100} height={70} borderRadius={10} widthType="percent" />
|
||||||
</>
|
</>
|
||||||
:
|
:
|
||||||
data.length > 0 ?
|
homeEvents.length > 0 ?
|
||||||
data.map((item, index) => {
|
homeEvents.map((item: Props, index: number) => {
|
||||||
return (
|
return (
|
||||||
<EventItem key={index} category={index % 2 == 0 ? 'purple' : 'orange'} onPress={() => { router.push(`/division/${item.idDivision}/calendar/${item.id}`) }} title={item.title} user={item.user_name} jamAwal={item.timeStart} jamAkhir={item.timeEnd} />
|
<EventItem key={index} category={index % 2 == 0 ? 'purple' : 'orange'} onPress={() => { router.push(`/division/${item.idDivision}/calendar/${item.id}`) }} title={item.title} user={item.user_name} jamAwal={item.timeStart} jamAkhir={item.timeEnd} />
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import Styles from "@/constants/Styles";
|
import Styles from "@/constants/Styles";
|
||||||
import { apiGetDataHome } from "@/lib/api";
|
import { apiGetDataHome } from "@/lib/api";
|
||||||
import { useAuthSession } from "@/providers/AuthProvider";
|
import { useAuthSession } from "@/providers/AuthProvider";
|
||||||
|
import { useTheme } from "@/providers/ThemeProvider";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import React, { useEffect, useState } from "react";
|
import React from "react";
|
||||||
import { Dimensions, View } from "react-native";
|
import { Dimensions, View } from "react-native";
|
||||||
import Carousel, { ICarouselInstance } from "react-native-reanimated-carousel";
|
import Carousel, { ICarouselInstance } from "react-native-reanimated-carousel";
|
||||||
import LabelStatus from "../labelStatus";
|
import LabelStatus from "../labelStatus";
|
||||||
@@ -10,7 +12,6 @@ import PaperGridContent from "../paperGridContent";
|
|||||||
import ProgressBar from "../progressBar";
|
import ProgressBar from "../progressBar";
|
||||||
import Skeleton from "../skeleton";
|
import Skeleton from "../skeleton";
|
||||||
import Text from "../Text";
|
import Text from "../Text";
|
||||||
import { useTheme } from "@/providers/ThemeProvider";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
id: string
|
id: string
|
||||||
@@ -25,45 +26,33 @@ export default function ProjectHome({ refreshing }: { refreshing: boolean }) {
|
|||||||
const { decryptToken, token } = useAuthSession()
|
const { decryptToken, token } = useAuthSession()
|
||||||
const ref = React.useRef<ICarouselInstance>(null);
|
const ref = React.useRef<ICarouselInstance>(null);
|
||||||
const width = Dimensions.get("window").width;
|
const width = Dimensions.get("window").width;
|
||||||
const [data, setData] = useState<Props[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
|
|
||||||
async function handleData(loading: boolean) {
|
// TanStack Query for Projects data
|
||||||
try {
|
const { data: homeProjects = [], isLoading } = useQuery({
|
||||||
setLoading(loading)
|
queryKey: ['homeData', 'kegiatan'],
|
||||||
|
queryFn: async () => {
|
||||||
const hasil = await decryptToken(String(token?.current))
|
const hasil = await decryptToken(String(token?.current))
|
||||||
const response = await apiGetDataHome({ cat: "kegiatan", user: hasil })
|
const response = await apiGetDataHome({ cat: "kegiatan", user: hasil })
|
||||||
setData(response.data)
|
return response.data as Props[]
|
||||||
} catch (error) {
|
},
|
||||||
console.error(error)
|
enabled: !!token?.current,
|
||||||
} finally {
|
staleTime: 0,
|
||||||
setLoading(false)
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (refreshing)
|
|
||||||
handleData(false)
|
|
||||||
}, [refreshing]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
handleData(true)
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[Styles.mb15]}>
|
<View style={[Styles.mb05]}>
|
||||||
<Text style={[Styles.textDefaultSemiBold, Styles.mb10]}>Kegiatan Terupdate</Text>
|
<Text style={[Styles.textDefaultSemiBold, Styles.mb10]}>Kegiatan Terupdate</Text>
|
||||||
{
|
{
|
||||||
loading ? (<Skeleton width={100} height={150} borderRadius={10} widthType="percent" />)
|
isLoading ? (<Skeleton width={100} height={150} borderRadius={10} widthType="percent" />)
|
||||||
:
|
:
|
||||||
data.length > 0 ?
|
homeProjects.length > 0 ?
|
||||||
<Carousel
|
<Carousel
|
||||||
ref={ref}
|
ref={ref}
|
||||||
style={{ width: "100%" }}
|
style={{ width: "100%" }}
|
||||||
width={width * 0.8}
|
width={width * 0.8}
|
||||||
height={235}
|
height={220}
|
||||||
data={data}
|
data={homeProjects}
|
||||||
loop={false}
|
loop={false}
|
||||||
autoPlay={false}
|
autoPlay={false}
|
||||||
autoPlayReverse={false}
|
autoPlayReverse={false}
|
||||||
@@ -71,24 +60,24 @@ export default function ProjectHome({ refreshing }: { refreshing: boolean }) {
|
|||||||
snapEnabled={true}
|
snapEnabled={true}
|
||||||
vertical={false}
|
vertical={false}
|
||||||
renderItem={({ index }) => (
|
renderItem={({ index }) => (
|
||||||
<PaperGridContent titleTail={1} content="carousel" onPress={() => { router.push(`/project/${data[index].id}`) }} title={data[index].title} headerColor="primary">
|
<PaperGridContent titleTail={1} content="carousel" onPress={() => { router.push(`/project/${homeProjects[index].id}`) }} title={homeProjects[index].title} headerColor="primary">
|
||||||
<ProgressBar value={data[index].progress} category="carousel" />
|
<ProgressBar value={homeProjects[index].progress} category="carousel" />
|
||||||
<View style={[Styles.rowSpaceBetween]}>
|
<View style={[Styles.rowSpaceBetween]}>
|
||||||
<Text style={[Styles.textDefault, { color: colors.dimmed }]}>{data[index].createdAt}</Text>
|
<Text style={[Styles.textDefault, { color: colors.dimmed }]}>{homeProjects[index].createdAt}</Text>
|
||||||
<LabelStatus
|
<LabelStatus
|
||||||
size="default"
|
size="default"
|
||||||
category={
|
category={
|
||||||
data[index].status === 0 ? 'secondary' :
|
homeProjects[index].status === 0 ? 'secondary' :
|
||||||
data[index].status === 1 ? 'warning' :
|
homeProjects[index].status === 1 ? 'warning' :
|
||||||
data[index].status === 2 ? 'success' :
|
homeProjects[index].status === 2 ? 'success' :
|
||||||
data[index].status === 3 ? 'error' :
|
homeProjects[index].status === 3 ? 'error' :
|
||||||
'secondary'
|
'secondary'
|
||||||
}
|
}
|
||||||
text={
|
text={
|
||||||
data[index].status === 0 ? 'SEGERA' :
|
homeProjects[index].status === 0 ? 'SEGERA' :
|
||||||
data[index].status === 1 ? 'DIKERJAKAN' :
|
homeProjects[index].status === 1 ? 'DIKERJAKAN' :
|
||||||
data[index].status === 2 ? 'SELESAI' :
|
homeProjects[index].status === 2 ? 'SELESAI' :
|
||||||
data[index].status === 3 ? 'DIBATALKAN' :
|
homeProjects[index].status === 3 ? 'DIBATALKAN' :
|
||||||
'SEGERA'
|
'SEGERA'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default function PaperGridContent({ content, children, title, headerColor
|
|||||||
const bgSource = activeTheme === 'light' ? bgLight : bgDark;
|
const bgSource = activeTheme === 'light' ? bgLight : bgDark;
|
||||||
return (
|
return (
|
||||||
<Pressable onPress={onPress}>
|
<Pressable onPress={onPress}>
|
||||||
<View style={[content == 'carousel' ? Styles.wrapGridCaraousel : Styles.wrapGridContent]}>
|
<View style={[content == 'carousel' ? Styles.wrapGridCaraousel : Styles.wrapGridContent, { backgroundColor: colors.card }]}>
|
||||||
{
|
{
|
||||||
headerColor == 'warning' ? (
|
headerColor == 'warning' ? (
|
||||||
<View style={[Styles.headerPaperGrid, ColorsStatus.warning]}>
|
<View style={[Styles.headerPaperGrid, ColorsStatus.warning]}>
|
||||||
@@ -41,7 +41,6 @@ export default function PaperGridContent({ content, children, title, headerColor
|
|||||||
}
|
}
|
||||||
<View style={[
|
<View style={[
|
||||||
contentPosition && contentPosition == 'top' ? Styles.contentPaperGrid2 : Styles.contentPaperGrid,
|
contentPosition && contentPosition == 'top' ? Styles.contentPaperGrid2 : Styles.contentPaperGrid,
|
||||||
{ backgroundColor: colors.card },
|
|
||||||
height ? { height: height } : {}
|
height ? { height: height } : {}
|
||||||
]}>
|
]}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import Constants from 'expo-constants';
|
|||||||
export const ConstEnv = {
|
export const ConstEnv = {
|
||||||
url_storage: Constants?.expoConfig?.extra?.URL_STORAGE,
|
url_storage: Constants?.expoConfig?.extra?.URL_STORAGE,
|
||||||
pass_encrypt: Constants?.expoConfig?.extra?.PASS_ENC,
|
pass_encrypt: Constants?.expoConfig?.extra?.PASS_ENC,
|
||||||
|
url_monitoring: Constants?.expoConfig?.extra?.URL_MONITORING,
|
||||||
|
key_api_monitoring: Constants?.expoConfig?.extra?.KEY_API_MONITORING,
|
||||||
firebase: {
|
firebase: {
|
||||||
apiKey: Constants?.expoConfig?.extra?.FIREBASE_API_KEY,
|
apiKey: Constants?.expoConfig?.extra?.FIREBASE_API_KEY,
|
||||||
authDomain: Constants?.expoConfig?.extra?.FIREBASE_AUTH_DOMAIN,
|
authDomain: Constants?.expoConfig?.extra?.FIREBASE_AUTH_DOMAIN,
|
||||||
|
|||||||
@@ -392,10 +392,10 @@ const Styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
wrapGridContent: {
|
wrapGridContent: {
|
||||||
shadowColor: '#171717',
|
shadowColor: '#171717',
|
||||||
shadowOffset: { width: 0, height: 4 },
|
shadowOffset: { width: 0, height: 0 },
|
||||||
shadowOpacity: 0.1,
|
shadowOpacity: 0.1,
|
||||||
shadowRadius: 10,
|
shadowRadius: 5,
|
||||||
elevation: 5,
|
elevation: 2,
|
||||||
borderRadius: 5,
|
borderRadius: 5,
|
||||||
marginBottom: 15
|
marginBottom: 15
|
||||||
},
|
},
|
||||||
@@ -403,12 +403,13 @@ const Styles = StyleSheet.create({
|
|||||||
width: '95%',
|
width: '95%',
|
||||||
height: 200,
|
height: 200,
|
||||||
shadowColor: '#171717',
|
shadowColor: '#171717',
|
||||||
shadowOffset: { width: 0, height: 4 },
|
shadowOffset: { width: 0, height: 0 },
|
||||||
shadowOpacity: 0.1,
|
shadowOpacity: 0.1,
|
||||||
shadowRadius: 4,
|
shadowRadius: 5,
|
||||||
elevation: 5,
|
elevation: 2,
|
||||||
borderRadius: 5,
|
borderRadius: 5,
|
||||||
marginLeft: 5
|
marginLeft: 5,
|
||||||
|
display: 'flex',
|
||||||
},
|
},
|
||||||
headerPaperGrid: {
|
headerPaperGrid: {
|
||||||
paddingVertical: 25,
|
paddingVertical: 25,
|
||||||
@@ -418,15 +419,13 @@ const Styles = StyleSheet.create({
|
|||||||
borderTopEndRadius: 5
|
borderTopEndRadius: 5
|
||||||
},
|
},
|
||||||
contentPaperGrid: {
|
contentPaperGrid: {
|
||||||
backgroundColor: 'white',
|
height: 125,
|
||||||
height: 150,
|
|
||||||
borderBottomEndRadius: 5,
|
borderBottomEndRadius: 5,
|
||||||
borderBottomStartRadius: 5,
|
borderBottomStartRadius: 5,
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
justifyContent: 'space-evenly'
|
justifyContent: 'space-evenly'
|
||||||
},
|
},
|
||||||
contentPaperGrid2: {
|
contentPaperGrid2: {
|
||||||
backgroundColor: 'white',
|
|
||||||
height: 100,
|
height: 100,
|
||||||
borderBottomEndRadius: 5,
|
borderBottomEndRadius: 5,
|
||||||
borderBottomStartRadius: 5,
|
borderBottomStartRadius: 5,
|
||||||
@@ -455,8 +454,8 @@ const Styles = StyleSheet.create({
|
|||||||
shadowColor: '#171717',
|
shadowColor: '#171717',
|
||||||
shadowOffset: { width: 0, height: 0 },
|
shadowOffset: { width: 0, height: 0 },
|
||||||
shadowOpacity: 0.1,
|
shadowOpacity: 0.1,
|
||||||
shadowRadius: 4,
|
shadowRadius: 5,
|
||||||
elevation: 5,
|
elevation: 2,
|
||||||
},
|
},
|
||||||
noShadow: {
|
noShadow: {
|
||||||
shadowColor: 'transparent',
|
shadowColor: 'transparent',
|
||||||
@@ -469,8 +468,8 @@ const Styles = StyleSheet.create({
|
|||||||
shadowColor: '#171717',
|
shadowColor: '#171717',
|
||||||
shadowOffset: { width: 0, height: 0 },
|
shadowOffset: { width: 0, height: 0 },
|
||||||
shadowOpacity: 0.1,
|
shadowOpacity: 0.1,
|
||||||
shadowRadius: 4,
|
shadowRadius: 5,
|
||||||
elevation: 5,
|
elevation: 2,
|
||||||
},
|
},
|
||||||
contentItemCenter: {
|
contentItemCenter: {
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
@@ -491,17 +490,17 @@ const Styles = StyleSheet.create({
|
|||||||
wrapItemDiscussion: {
|
wrapItemDiscussion: {
|
||||||
padding: 15,
|
padding: 15,
|
||||||
borderRadius: 5,
|
borderRadius: 5,
|
||||||
borderColor: '#d6d8f6',
|
// borderColor: '#d6d8f6',
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
},
|
},
|
||||||
wrapItemBorderBottom: {
|
wrapItemBorderBottom: {
|
||||||
padding: 10,
|
padding: 10,
|
||||||
borderColor: '#d6d8f6',
|
// borderColor: '#d6d8f6',
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
},
|
},
|
||||||
wrapItemBorderAll: {
|
wrapItemBorderAll: {
|
||||||
padding: 10,
|
padding: 10,
|
||||||
borderColor: '#d6d8f6',
|
// borderColor: '#d6d8f6',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderRadius: 5,
|
borderRadius: 5,
|
||||||
marginBottom: 5
|
marginBottom: 5
|
||||||
@@ -749,10 +748,10 @@ const Styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
wrapHomeCarousel: {
|
wrapHomeCarousel: {
|
||||||
shadowColor: '#171717',
|
shadowColor: '#171717',
|
||||||
shadowOffset: { width: 0, height: 5 },
|
shadowOffset: { width: 0, height: 4 },
|
||||||
shadowOpacity: 0.2,
|
shadowOpacity: 0.15,
|
||||||
shadowRadius: 5,
|
shadowRadius: 8,
|
||||||
elevation: 50,
|
elevation: 5,
|
||||||
},
|
},
|
||||||
modalOverlay: {
|
modalOverlay: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -764,7 +763,7 @@ const Styles = StyleSheet.create({
|
|||||||
width: '80%',
|
width: '80%',
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
elevation: 5,
|
elevation: 2,
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: { width: 0, height: 2 },
|
shadowOffset: { width: 0, height: 2 },
|
||||||
shadowOpacity: 0.25,
|
shadowOpacity: 0.25,
|
||||||
|
|||||||
32
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
## Routing (Expo Router — file-based)
|
||||||
|
|
||||||
|
- `app/index.tsx` — Login/splash (public); OTP verification is handled inline via `components/auth/viewVerification.tsx` (not a separate route)
|
||||||
|
- `app/(application)/` — All authenticated screens; Expo Router enforces auth guard here
|
||||||
|
- Deep-link navigation is handled by `lib/pushToPage.ts`, which maps notification payloads to routes
|
||||||
|
|
||||||
|
## State Management (three layers)
|
||||||
|
|
||||||
|
1. **Context** (`providers/`) — Auth (token encryption/decryption via CryptoES.AES), Theme (light/dark, persisted to AsyncStorage), and React Query client
|
||||||
|
2. **Redux Toolkit** (`lib/store.ts` + slices) — Feature-level state for CRUD operations. Slices follow a naming pattern: `*Slice.ts` for read state, `*Update.ts`/`*Create.ts` for mutation state
|
||||||
|
3. **TanStack React Query** — All server data fetching; configured with 5-min stale time, 24-hour cache retention, 2 retries, and AsyncStorage persistence for offline support
|
||||||
|
|
||||||
|
## API Layer (`lib/api.ts`)
|
||||||
|
|
||||||
|
Single file defining 50+ Axios-based endpoints. The Axios instance reads `baseURL` from `Constants.expoConfig.extra.URL_API` (set in `.env` via `app.config.js`). Authentication uses Bearer tokens in headers. File uploads use `FormData` with `multipart/form-data`.
|
||||||
|
|
||||||
|
Three separate backend services are integrated:
|
||||||
|
- **REST API** (axios) — main business logic
|
||||||
|
- **WhatsApp server** — OTP delivery (separate token in `.env`)
|
||||||
|
- **Firebase** — real-time database (`lib/firebaseDatabase.ts`) and push notifications (`lib/useNotification.ts`, `lib/registerForPushNotificationsAsync.ts`)
|
||||||
|
|
||||||
|
## Providers Initialization Order
|
||||||
|
|
||||||
|
`app/_layout.tsx` wraps the app in: `ErrorBoundary` → `NotifierWrapper` → `ThemeProvider` → `QueryProvider` → `AuthProvider` → navigation stack. Redux `store` is provided inside `app/(application)/_layout.tsx`, not at the root.
|
||||||
|
|
||||||
|
## Error Boundary
|
||||||
|
|
||||||
|
`components/ErrorBoundary.tsx` is a class component (required by React) wrapping the entire app. It uses React Native's built-in `Text` — **do not replace it with the custom `components/Text.tsx`** as that pulls in `ThemeProvider` → `AsyncStorage`, which breaks Jest tests.
|
||||||
|
|
||||||
|
Tests for ErrorBoundary live in `__tests__/ErrorBoundary-test.tsx` and use `@testing-library/react-native`.
|
||||||
13
docs/CONVENTIONS.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Key Conventions
|
||||||
|
|
||||||
|
**Imports:** Use `@/` alias (maps to project root, configured in `tsconfig.json`). Never use relative paths like `../../`.
|
||||||
|
|
||||||
|
**Utility functions:** Prefixed with `fun_` (e.g., `lib/fun_stringToDate.ts`, `lib/fun_validateName.ts`).
|
||||||
|
|
||||||
|
**Styling:** Use theme-aware colors from `useTheme()` hook. Global `StyleSheet` definitions live in `constants/Styles.ts`. Color tokens are in `constants/Colors.ts` with explicit `light`/`dark` variants.
|
||||||
|
|
||||||
|
**Component structure:** Feature-specific subdirectories under `components/` (e.g., `components/announcement/`) typically contain a header component alongside list/card components for that feature.
|
||||||
|
|
||||||
|
**Environment config:** All env vars are declared in `.env`, exposed through `app.config.js` `extra` field, and accessed via `Constants.expoConfig.extra.*` or the `constants/ConstEnv.ts` wrapper.
|
||||||
|
|
||||||
|
**EAS builds:** Profiles are `development`, `preview`, and `production` in `eas.json`. Production builds auto-increment the app version via the `bump` script.
|
||||||
3
eas.json
@@ -28,7 +28,8 @@
|
|||||||
"buildType": "app-bundle"
|
"buildType": "app-bundle"
|
||||||
},
|
},
|
||||||
"ios": {
|
"ios": {
|
||||||
"simulator": false
|
"simulator": false,
|
||||||
|
"image": "latest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2.0.5</string>
|
<string>2.1.0</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>7</string>
|
<string>9</string>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ PODS:
|
|||||||
- DoubleConversion (1.1.6)
|
- DoubleConversion (1.1.6)
|
||||||
- EXApplication (6.1.5):
|
- EXApplication (6.1.5):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- EXConstants (17.1.7):
|
- EXConstants (17.1.8):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- EXImageLoader (5.1.0):
|
- EXImageLoader (5.1.0):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
@@ -11,9 +11,9 @@ PODS:
|
|||||||
- EXJSONUtils (0.15.0)
|
- EXJSONUtils (0.15.0)
|
||||||
- EXManifests (0.16.6):
|
- EXManifests (0.16.6):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- EXNotifications (0.31.4):
|
- EXNotifications (0.31.5):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- Expo (53.0.20):
|
- Expo (53.0.27):
|
||||||
- DoubleConversion
|
- DoubleConversion
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- glog
|
- glog
|
||||||
@@ -282,7 +282,7 @@ PODS:
|
|||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoHaptics (14.1.4):
|
- ExpoHaptics (14.1.4):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoHead (5.1.4):
|
- ExpoHead (5.1.11):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoImagePicker (16.1.4):
|
- ExpoImagePicker (16.1.4):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
@@ -324,7 +324,7 @@ PODS:
|
|||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoSymbols (0.4.5):
|
- ExpoSymbols (0.4.5):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoSystemUI (5.0.10):
|
- ExpoSystemUI (5.0.11):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoWebBrowser (14.2.0):
|
- ExpoWebBrowser (14.2.0):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
@@ -1703,6 +1703,8 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/bridging
|
- ReactCommon/turbomodule/bridging
|
||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- Yoga
|
- Yoga
|
||||||
|
- react-native-netinfo (12.0.1):
|
||||||
|
- React-Core
|
||||||
- react-native-render-html (6.3.4):
|
- react-native-render-html (6.3.4):
|
||||||
- React-Core
|
- React-Core
|
||||||
- react-native-safe-area-context (5.4.0):
|
- react-native-safe-area-context (5.4.0):
|
||||||
@@ -2296,6 +2298,7 @@ DEPENDENCIES:
|
|||||||
- react-native-blob-util (from `../node_modules/react-native-blob-util`)
|
- react-native-blob-util (from `../node_modules/react-native-blob-util`)
|
||||||
- react-native-date-picker (from `../node_modules/react-native-date-picker`)
|
- react-native-date-picker (from `../node_modules/react-native-date-picker`)
|
||||||
- react-native-image-picker (from `../node_modules/react-native-image-picker`)
|
- react-native-image-picker (from `../node_modules/react-native-image-picker`)
|
||||||
|
- "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)"
|
||||||
- react-native-render-html (from `../node_modules/react-native-render-html`)
|
- react-native-render-html (from `../node_modules/react-native-render-html`)
|
||||||
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
|
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
|
||||||
- react-native-webview (from `../node_modules/react-native-webview`)
|
- react-native-webview (from `../node_modules/react-native-webview`)
|
||||||
@@ -2505,6 +2508,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: "../node_modules/react-native-date-picker"
|
:path: "../node_modules/react-native-date-picker"
|
||||||
react-native-image-picker:
|
react-native-image-picker:
|
||||||
:path: "../node_modules/react-native-image-picker"
|
:path: "../node_modules/react-native-image-picker"
|
||||||
|
react-native-netinfo:
|
||||||
|
:path: "../node_modules/@react-native-community/netinfo"
|
||||||
react-native-render-html:
|
react-native-render-html:
|
||||||
:path: "../node_modules/react-native-render-html"
|
:path: "../node_modules/react-native-render-html"
|
||||||
react-native-safe-area-context:
|
react-native-safe-area-context:
|
||||||
@@ -2600,12 +2605,12 @@ SPEC CHECKSUMS:
|
|||||||
boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90
|
boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90
|
||||||
DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb
|
DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb
|
||||||
EXApplication: 1e06972201838375ca1ec1ba34d586a98a5dc718
|
EXApplication: 1e06972201838375ca1ec1ba34d586a98a5dc718
|
||||||
EXConstants: 98bcf0f22b820f9b28f9fee55ff2daededadd2f8
|
EXConstants: d3d551cb154718f5161c4247304e96aa59f6cca7
|
||||||
EXImageLoader: 4d3d3284141f1a45006cc4d0844061c182daf7ee
|
EXImageLoader: 4d3d3284141f1a45006cc4d0844061c182daf7ee
|
||||||
EXJSONUtils: 1d3e4590438c3ee593684186007028a14b3686cd
|
EXJSONUtils: 1d3e4590438c3ee593684186007028a14b3686cd
|
||||||
EXManifests: 691a779b04e4f2c96da46fb9bef4f86174fefcb5
|
EXManifests: 691a779b04e4f2c96da46fb9bef4f86174fefcb5
|
||||||
EXNotifications: be5e949edf1d60b70e77178b81aa505298fadd07
|
EXNotifications: 6770976336aacdc7dc7aed7b538dd8f7ad2c55e8
|
||||||
Expo: 8685113c16058e8b3eb101dd52d6c8bca260bbea
|
Expo: 052536aae777d5156739c960afd6aa54881df42a
|
||||||
expo-dev-client: 9b1e78baf0dd87b005f035d180bbb07c05917fad
|
expo-dev-client: 9b1e78baf0dd87b005f035d180bbb07c05917fad
|
||||||
expo-dev-launcher: 2f95084d36be3d9106790bea7a933a0d34210646
|
expo-dev-launcher: 2f95084d36be3d9106790bea7a933a0d34210646
|
||||||
expo-dev-menu: 1456232a68c883078b61c02b7fa5b01d8a5ab840
|
expo-dev-menu: 1456232a68c883078b61c02b7fa5b01d8a5ab840
|
||||||
@@ -2618,7 +2623,7 @@ SPEC CHECKSUMS:
|
|||||||
ExpoFileSystem: 7f92f7be2f5c5ed40a7c9efc8fa30821181d9d63
|
ExpoFileSystem: 7f92f7be2f5c5ed40a7c9efc8fa30821181d9d63
|
||||||
ExpoFont: cf508bc2e6b70871e05386d71cab927c8524cc8e
|
ExpoFont: cf508bc2e6b70871e05386d71cab927c8524cc8e
|
||||||
ExpoHaptics: 0ff6e0d83cd891178a306e548da1450249d54500
|
ExpoHaptics: 0ff6e0d83cd891178a306e548da1450249d54500
|
||||||
ExpoHead: a7b66cbaeeb51f4a85338d335a0f5467e29a2c90
|
ExpoHead: cfc12096c9a68cbe25de93a8bfc4781c7689467e
|
||||||
ExpoImagePicker: 0963da31800c906e01c03e25d7c849f16ebf02a2
|
ExpoImagePicker: 0963da31800c906e01c03e25d7c849f16ebf02a2
|
||||||
ExpoKeepAwake: bf0811570c8da182bfb879169437d4de298376e7
|
ExpoKeepAwake: bf0811570c8da182bfb879169437d4de298376e7
|
||||||
ExpoLinearGradient: 7734c8059972fcf691fb4330bcdf3390960a152d
|
ExpoLinearGradient: 7734c8059972fcf691fb4330bcdf3390960a152d
|
||||||
@@ -2628,7 +2633,7 @@ SPEC CHECKSUMS:
|
|||||||
ExpoSharing: b0377be82430d07398c6a4cd60b5a15696accbd3
|
ExpoSharing: b0377be82430d07398c6a4cd60b5a15696accbd3
|
||||||
ExpoSplashScreen: 1c22c5d37647106e42d4ae1582bb6d0dda3b2385
|
ExpoSplashScreen: 1c22c5d37647106e42d4ae1582bb6d0dda3b2385
|
||||||
ExpoSymbols: c5612a90fb9179cdaebcd19bea9d8c69e5d3b859
|
ExpoSymbols: c5612a90fb9179cdaebcd19bea9d8c69e5d3b859
|
||||||
ExpoSystemUI: c2724f9d5af6b1bb74e013efadf9c6a8fae547a2
|
ExpoSystemUI: 433a971503b99020318518ed30a58204288bab2d
|
||||||
ExpoWebBrowser: dc39a88485f007e61a3dff05d6a75f22ab4a2e92
|
ExpoWebBrowser: dc39a88485f007e61a3dff05d6a75f22ab4a2e92
|
||||||
EXUpdatesInterface: 7ff005b7af94ee63fa452ea7bb95d7a8ff40277a
|
EXUpdatesInterface: 7ff005b7af94ee63fa452ea7bb95d7a8ff40277a
|
||||||
fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6
|
fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6
|
||||||
@@ -2683,6 +2688,7 @@ SPEC CHECKSUMS:
|
|||||||
react-native-blob-util: 45eb0e23b243b48955d231414ca5ee4da2439968
|
react-native-blob-util: 45eb0e23b243b48955d231414ca5ee4da2439968
|
||||||
react-native-date-picker: 2eca217a8fb09c517f5bb6b23978718c6cec59ec
|
react-native-date-picker: 2eca217a8fb09c517f5bb6b23978718c6cec59ec
|
||||||
react-native-image-picker: 0c4a539c4e67fbe3977916cd2c8d0e4c67f00a8c
|
react-native-image-picker: 0c4a539c4e67fbe3977916cd2c8d0e4c67f00a8c
|
||||||
|
react-native-netinfo: bed7e7b8f68e22e0862a77d7df28d31faa66375d
|
||||||
react-native-render-html: 5afc4751f1a98621b3009432ef84c47019dcb2bd
|
react-native-render-html: 5afc4751f1a98621b3009432ef84c47019dcb2bd
|
||||||
react-native-safe-area-context: 9d72abf6d8473da73033b597090a80b709c0b2f1
|
react-native-safe-area-context: 9d72abf6d8473da73033b597090a80b709c0b2f1
|
||||||
react-native-webview: 3df1192782174d1bd23f6a0f5a4fec3cdcca9954
|
react-native-webview: 3df1192782174d1bd23f6a0f5a4fec3cdcca9954
|
||||||
|
|||||||
34
lib/api.ts
@@ -1,10 +1,22 @@
|
|||||||
import axios from 'axios';
|
import axios, { AxiosError } from 'axios';
|
||||||
import Constants from 'expo-constants';
|
import Constants from 'expo-constants';
|
||||||
|
import { logError } from '@/lib/errorLogger';
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: Constants?.expoConfig?.extra?.URL_API
|
baseURL: Constants?.expoConfig?.extra?.URL_API
|
||||||
});
|
});
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error: AxiosError) => {
|
||||||
|
const status = error.response?.status;
|
||||||
|
const url = error.config?.url ?? 'unknown endpoint';
|
||||||
|
const description = `API error ${status ?? 'network'} on ${url}`;
|
||||||
|
logError(description, error);
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export const apiCheckPhoneLogin = async (body: { phone: string }) => {
|
export const apiCheckPhoneLogin = async (body: { phone: string }) => {
|
||||||
const response = await api.post('/auth/login', body)
|
const response = await api.post('/auth/login', body)
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -13,18 +25,21 @@ export const apiCheckPhoneLogin = async (body: { phone: string }) => {
|
|||||||
export const apiSendOtp = async (body: { phone: string, otp: number }) => {
|
export const apiSendOtp = async (body: { phone: string, otp: number }) => {
|
||||||
const message = "Desa+\nMasukkan kode ini " + body.otp + " pada aplikasi Desa+ anda. Jangan berikan pada siapapun."
|
const message = "Desa+\nMasukkan kode ini " + body.otp + " pada aplikasi Desa+ anda. Jangan berikan pada siapapun."
|
||||||
const textFix = encodeURIComponent(message)
|
const textFix = encodeURIComponent(message)
|
||||||
// const res = await axios.get(`${Constants.expoConfig?.extra?.URL_OTP}/code?nom=${body.phone}&text=*Desa%2B*%0AMasukkan%20kode%20ini%20*${encodeURIComponent(body.otp)}*%20pada%20aplikasi%20Desa%2B%20anda.%20Jangan%20berikan%20pada%20siapapun.`)
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${Constants.expoConfig?.extra?.URL_OTP}/code?nom=${body.phone}&text=${textFix}`,
|
`${Constants.expoConfig?.extra?.URL_OTP}/api/wa/send-text`,
|
||||||
{
|
{
|
||||||
cache: "no-cache",
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${Constants.expoConfig?.extra?.WA_SERVER_TOKEN}`,
|
Authorization: `Bearer ${Constants.expoConfig?.extra?.WA_SERVER_TOKEN}`,
|
||||||
},
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
number: body.phone,
|
||||||
|
text: message
|
||||||
|
})
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
return res.status
|
return res.status
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -740,16 +755,21 @@ export const apiShareDocument = async (data: { dataDivision: any[], dataItem: an
|
|||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const apiRegisteredToken = async (data: { user: string, token: string }) => {
|
export const apiRegisteredToken = async (data: { user: string, token: string, category?: string }) => {
|
||||||
const response = await api.post(`/mobile/auth-token`, data)
|
const response = await api.post(`/mobile/auth-token`, data)
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const apiUnregisteredToken = async (data: { user: string, token: string }) => {
|
export const apiUnregisteredToken = async (data: { user: string, token: string, category?: string }) => {
|
||||||
const response = await api.put(`/mobile/auth-token`, data)
|
const response = await api.put(`/mobile/auth-token`, data)
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const apiGetCheckToken = async (data: { user: string, token: string }) => {
|
||||||
|
const response = await api.post(`mobile/auth-token/check`, data);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
export const apiGetNotification = async ({ user, page }: { user: string, page?: number }) => {
|
export const apiGetNotification = async ({ user, page }: { user: string, page?: number }) => {
|
||||||
const response = await api.get(`mobile/home/notification?user=${user}&page=${page}`);
|
const response = await api.get(`mobile/home/notification?user=${user}&page=${page}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
100
lib/errorLogger.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import NetInfo from '@react-native-community/netinfo';
|
||||||
|
import Constants from 'expo-constants';
|
||||||
|
import * as Device from 'expo-device';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
import { ConstEnv } from '@/constants/ConstEnv';
|
||||||
|
|
||||||
|
const QUEUE_KEY = 'error_log_queue';
|
||||||
|
const APP_NAME = 'desa-plus';
|
||||||
|
|
||||||
|
type ErrorPayload = {
|
||||||
|
affectedVersion: string;
|
||||||
|
device: string;
|
||||||
|
os: string;
|
||||||
|
description: string;
|
||||||
|
app: string;
|
||||||
|
source: 'SYSTEM';
|
||||||
|
stackTrace: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildPayload(description: string, stackTrace?: string): ErrorPayload {
|
||||||
|
const platformName = Platform.OS === 'ios' ? 'iOS' : 'Android';
|
||||||
|
return {
|
||||||
|
affectedVersion: Constants.expoConfig?.version ?? 'unknown',
|
||||||
|
device: Device.modelName ?? 'unknown',
|
||||||
|
os: `${platformName} ${Device.osVersion ?? ''}`.trim(),
|
||||||
|
description,
|
||||||
|
app: APP_NAME,
|
||||||
|
source: 'SYSTEM',
|
||||||
|
stackTrace: stackTrace ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendToMonitoring(payload: ErrorPayload): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${ConstEnv.url_monitoring}/api/bugs`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-API-Key': ConstEnv.key_api_monitoring ?? '',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
return res.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enqueue(payload: ErrorPayload): Promise<void> {
|
||||||
|
try {
|
||||||
|
const raw = await AsyncStorage.getItem(QUEUE_KEY);
|
||||||
|
const queue: ErrorPayload[] = raw ? JSON.parse(raw) : [];
|
||||||
|
queue.push(payload);
|
||||||
|
await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function flushErrorQueue(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const state = await NetInfo.fetch();
|
||||||
|
if (!state.isConnected) return;
|
||||||
|
|
||||||
|
const raw = await AsyncStorage.getItem(QUEUE_KEY);
|
||||||
|
if (!raw) return;
|
||||||
|
|
||||||
|
const queue: ErrorPayload[] = JSON.parse(raw);
|
||||||
|
if (queue.length === 0) return;
|
||||||
|
|
||||||
|
const failed: ErrorPayload[] = [];
|
||||||
|
for (const payload of queue) {
|
||||||
|
const ok = await sendToMonitoring(payload);
|
||||||
|
if (!ok) failed.push(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed.length > 0) {
|
||||||
|
await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(failed));
|
||||||
|
} else {
|
||||||
|
await AsyncStorage.removeItem(QUEUE_KEY);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logError(description: string, error?: Error | unknown): Promise<void> {
|
||||||
|
const stackTrace = error instanceof Error ? (error.stack ?? error.message) : String(error ?? '');
|
||||||
|
const payload = buildPayload(description, stackTrace);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = await NetInfo.fetch();
|
||||||
|
if (state.isConnected) {
|
||||||
|
const ok = await sendToMonitoring(payload);
|
||||||
|
if (!ok) await enqueue(payload);
|
||||||
|
} else {
|
||||||
|
await enqueue(payload);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await enqueue(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { ConstEnv } from '@/constants/ConstEnv';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { getApp, getApps, initializeApp } from '@react-native-firebase/app';
|
import { getApp, getApps, initializeApp } from '@react-native-firebase/app';
|
||||||
import {
|
import {
|
||||||
getMessaging,
|
getMessaging,
|
||||||
@@ -6,8 +8,7 @@ import {
|
|||||||
} from '@react-native-firebase/messaging';
|
} from '@react-native-firebase/messaging';
|
||||||
import * as Notifications from 'expo-notifications';
|
import * as Notifications from 'expo-notifications';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Linking, PermissionsAndroid, Platform } from 'react-native';
|
import { Linking, Platform } from 'react-native';
|
||||||
import { ConstEnv } from '@/constants/ConstEnv';
|
|
||||||
|
|
||||||
const RNfirebaseConfig = {
|
const RNfirebaseConfig = {
|
||||||
apiKey: ConstEnv.firebase.apiKey,
|
apiKey: ConstEnv.firebase.apiKey,
|
||||||
@@ -39,13 +40,15 @@ const initializeFirebase = async () => {
|
|||||||
|
|
||||||
export const checkPermission = async () => {
|
export const checkPermission = async () => {
|
||||||
try {
|
try {
|
||||||
if (Platform.OS === 'android') {
|
// Cek status permission sekarang
|
||||||
return await PermissionsAndroid.check(
|
const { status } = await Notifications.getPermissionsAsync();
|
||||||
PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS
|
|
||||||
);
|
if (status === 'granted') {
|
||||||
} else if (Platform.OS === 'ios') {
|
return true;
|
||||||
const { status } = await Notifications.getPermissionsAsync();
|
}
|
||||||
return status === 'granted';
|
|
||||||
|
if (status === 'denied') {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Error checking notification permissions:', err);
|
console.warn('Error checking notification permissions:', err);
|
||||||
@@ -63,23 +66,48 @@ export const openSettings = () => {
|
|||||||
|
|
||||||
export const requestPermission = async () => {
|
export const requestPermission = async () => {
|
||||||
try {
|
try {
|
||||||
if (Platform.OS === 'android') {
|
const existing = await AsyncStorage.getItem('@notification_permission');
|
||||||
const cek = await PermissionsAndroid.check(
|
const { status: currentStatus, canAskAgain } = await Notifications.getPermissionsAsync();
|
||||||
PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS
|
|
||||||
);
|
// Jika baru pertama kali (fresh install / storage belum ada)
|
||||||
if (!cek) {
|
if (existing === null) {
|
||||||
const granted = await PermissionsAndroid.request(
|
// Cek apakah OS memungkinkan untuk memunculkan popup
|
||||||
PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS
|
if (currentStatus === 'undetermined' || (currentStatus === 'denied' && canAskAgain)) {
|
||||||
);
|
const { status: newStatus } = await Notifications.requestPermissionsAsync();
|
||||||
return granted === PermissionsAndroid.RESULTS.GRANTED;
|
await AsyncStorage.setItem('@notification_permission', newStatus === 'granted' ? 'true' : 'false');
|
||||||
|
return newStatus === 'granted';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jika OS sudah granted (sudah diijinkan entah lewat mana), simpan true
|
||||||
|
if (currentStatus === 'granted') {
|
||||||
|
await AsyncStorage.setItem('@notification_permission', 'true');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jika OS denied dan tidak bisa minta lagi (disables)
|
||||||
|
if (currentStatus === 'denied' && !canAskAgain) {
|
||||||
|
await AsyncStorage.setItem('@notification_permission', 'false');
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
} else if (Platform.OS === 'ios') {
|
|
||||||
const { status } = await Notifications.requestPermissionsAsync();
|
|
||||||
return status === 'granted';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const osPermission = await checkPermission();
|
||||||
|
// Jika sudah pernah di-request sebelumnya (storage sudah ada)
|
||||||
|
// Selalu sinkronkan jika OS menyatakan Denied
|
||||||
|
if (osPermission === false) {
|
||||||
|
await AsyncStorage.setItem('@notification_permission', 'false');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jika OS Granted, ikuti nilai tersimpan di storage (menghargai in-app toggle user)
|
||||||
|
if (osPermission === true) {
|
||||||
|
return existing === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Error requesting notification permissions:', err);
|
console.warn('Error requesting notification permissions:', err);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -24,12 +24,16 @@
|
|||||||
"@react-native-clipboard/clipboard": "^1.16.3",
|
"@react-native-clipboard/clipboard": "^1.16.3",
|
||||||
"@react-native-community/cli": "^19.1.0",
|
"@react-native-community/cli": "^19.1.0",
|
||||||
"@react-native-community/datetimepicker": "8.4.1",
|
"@react-native-community/datetimepicker": "8.4.1",
|
||||||
|
"@react-native-community/netinfo": "^12.0.1",
|
||||||
"@react-native-firebase/app": "^22.4.0",
|
"@react-native-firebase/app": "^22.4.0",
|
||||||
"@react-native-firebase/database": "^22.4.0",
|
"@react-native-firebase/database": "^22.4.0",
|
||||||
"@react-native-firebase/messaging": "^22.2.1",
|
"@react-native-firebase/messaging": "^22.2.1",
|
||||||
"@react-navigation/bottom-tabs": "^7.2.0",
|
"@react-navigation/bottom-tabs": "^7.2.0",
|
||||||
"@react-navigation/native": "^7.0.14",
|
"@react-navigation/native": "^7.0.14",
|
||||||
"@reduxjs/toolkit": "^2.7.0",
|
"@reduxjs/toolkit": "^2.7.0",
|
||||||
|
"@tanstack/query-async-storage-persister": "^5.99.2",
|
||||||
|
"@tanstack/react-query": "^5.99.2",
|
||||||
|
"@tanstack/react-query-persist-client": "^5.99.2",
|
||||||
"@types/formidable": "^3.4.5",
|
"@types/formidable": "^3.4.5",
|
||||||
"axios": "^1.8.4",
|
"axios": "^1.8.4",
|
||||||
"crypto-es": "^2.1.0",
|
"crypto-es": "^2.1.0",
|
||||||
@@ -97,13 +101,14 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.25.2",
|
"@babel/core": "^7.25.2",
|
||||||
"@react-native-community/cli-platform-ios": "^18.0.0",
|
"@react-native-community/cli-platform-ios": "^18.0.0",
|
||||||
|
"@testing-library/react-native": "^13.3.3",
|
||||||
"@types/crypto-js": "^4.2.2",
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@types/jest": "^29.5.12",
|
"@types/jest": "^29.5.12",
|
||||||
"@types/react": "~19.0.10",
|
"@types/react": "~19.0.10",
|
||||||
"@types/react-test-renderer": "^18.3.0",
|
"@types/react-test-renderer": "^18.3.0",
|
||||||
"jest": "^29.2.1",
|
"jest": "^29.2.1",
|
||||||
"jest-expo": "~53.0.5",
|
"jest-expo": "~53.0.5",
|
||||||
"react-test-renderer": "18.3.1",
|
"react-test-renderer": "19.0.0",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { ConstEnv } from '@/constants/ConstEnv';
|
import { ConstEnv } from '@/constants/ConstEnv';
|
||||||
import { apiRegisteredToken, apiUnregisteredToken } from '@/lib/api';
|
import { apiRegisteredToken, apiUnregisteredToken } from '@/lib/api';
|
||||||
import { getToken, requestPermission } from '@/lib/useNotification';
|
import { getToken } from '@/lib/useNotification';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import CryptoES from "crypto-es";
|
import CryptoES from "crypto-es";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { createContext, MutableRefObject, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
import { createContext, MutableRefObject, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||||
@@ -30,6 +31,7 @@ export function useAuthSession() {
|
|||||||
export default function AuthProvider({ children }: { children: ReactNode }): ReactNode {
|
export default function AuthProvider({ children }: { children: ReactNode }): ReactNode {
|
||||||
const tokenRef = useRef<string | null>(null);
|
const tokenRef = useRef<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async (): Promise<void> => {
|
(async (): Promise<void> => {
|
||||||
@@ -52,16 +54,11 @@ export default function AuthProvider({ children }: { children: ReactNode }): Rea
|
|||||||
|
|
||||||
const signIn = useCallback(async (token: string) => {
|
const signIn = useCallback(async (token: string) => {
|
||||||
const hasil = await decryptToken(String(token))
|
const hasil = await decryptToken(String(token))
|
||||||
const permission = await requestPermission()
|
const permissionStorage = await AsyncStorage.getItem('@notification_permission')
|
||||||
if (permission) {
|
if (permissionStorage === "true") {
|
||||||
|
const tokenDevice = await getToken()
|
||||||
try {
|
try {
|
||||||
// if (Platform.OS === 'android') {
|
await apiRegisteredToken({ user: hasil, token: String(tokenDevice) })
|
||||||
const tokenDevice = await getToken()
|
|
||||||
const register = await apiRegisteredToken({ user: hasil, token: String(tokenDevice) })
|
|
||||||
// }else{
|
|
||||||
// const tokenDevice = await getToken()
|
|
||||||
// const register = await apiRegisteredToken({ user: hasil, token: String(tokenDevice) })
|
|
||||||
// }
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -71,6 +68,7 @@ export default function AuthProvider({ children }: { children: ReactNode }): Rea
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
await apiRegisteredToken({ user: hasil, token: "" })
|
||||||
await AsyncStorage.setItem('@token', token);
|
await AsyncStorage.setItem('@token', token);
|
||||||
tokenRef.current = token;
|
tokenRef.current = token;
|
||||||
router.replace('/home')
|
router.replace('/home')
|
||||||
@@ -82,7 +80,7 @@ export default function AuthProvider({ children }: { children: ReactNode }): Rea
|
|||||||
const hasil = await decryptToken(String(tokenRef.current))
|
const hasil = await decryptToken(String(tokenRef.current))
|
||||||
// if (Platform.OS === 'android') {
|
// if (Platform.OS === 'android') {
|
||||||
const token = await getToken()
|
const token = await getToken()
|
||||||
const response = await apiUnregisteredToken({ user: hasil, token: String(token) })
|
await apiUnregisteredToken({ user: hasil, token: String(token) })
|
||||||
// }else{
|
// }else{
|
||||||
// const response = await apiUnregisteredToken({ user: hasil, token: "" })
|
// const response = await apiUnregisteredToken({ user: hasil, token: "" })
|
||||||
// }
|
// }
|
||||||
@@ -91,6 +89,7 @@ export default function AuthProvider({ children }: { children: ReactNode }): Rea
|
|||||||
} finally {
|
} finally {
|
||||||
await AsyncStorage.setItem('@token', '');
|
await AsyncStorage.setItem('@token', '');
|
||||||
tokenRef.current = null;
|
tokenRef.current = null;
|
||||||
|
queryClient.clear();
|
||||||
router.replace('/');
|
router.replace('/');
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
58
providers/QueryProvider.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { QueryClient, QueryClientProvider, focusManager, onlineManager } from '@tanstack/react-query';
|
||||||
|
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
|
||||||
|
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import NetInfo from '@react-native-community/netinfo';
|
||||||
|
import { AppState, Platform, AppStateStatus } from 'react-native';
|
||||||
|
|
||||||
|
// 1. Configure the QueryClient
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
// Data is considered stale after 5 minutes
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
// Keep unused data in cache for 24 hours
|
||||||
|
gcTime: 24 * 60 * 60 * 1000,
|
||||||
|
// Retry failed queries 2 times
|
||||||
|
retry: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Configure the AsyncStorage persister
|
||||||
|
const asyncStoragePersister = createAsyncStoragePersister({
|
||||||
|
storage: AsyncStorage,
|
||||||
|
// Key used to store cache in AsyncStorage
|
||||||
|
key: 'OFFLINE_CACHE',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Configure the Online Manager for NetInfo
|
||||||
|
onlineManager.setEventListener((setOnline) => {
|
||||||
|
return NetInfo.addEventListener((state) => {
|
||||||
|
setOnline(!!state.isConnected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Configure the Focus Manager for AppState
|
||||||
|
function onAppStateChange(status: AppStateStatus) {
|
||||||
|
if (Platform.OS !== 'web') {
|
||||||
|
focusManager.setFocused(status === 'active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QueryProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
useEffect(() => {
|
||||||
|
const subscription = AppState.addEventListener('change', onAppStateChange);
|
||||||
|
return () => subscription.remove();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PersistQueryClientProvider
|
||||||
|
client={queryClient}
|
||||||
|
persistOptions={{ persister: asyncStoragePersister }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</PersistQueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||