Compare commits
24 Commits
amalia/25-
...
join
| Author | SHA1 | Date | |
|---|---|---|---|
| 887e787a99 | |||
| 772551a917 | |||
| 555b9e4037 | |||
| d4b4db4251 | |||
| 17d92cba25 | |||
| e1b62be6da | |||
| b2b125c410 | |||
| 1cfecbbdd5 | |||
| 21006e8eee | |||
| 91231d60e4 | |||
| 7174e27be1 | |||
| 9d4b931aa6 | |||
| 166d8f1c16 | |||
| 7060a2d165 | |||
| d6217aecf1 | |||
| 608381673f | |||
| 3cc7f76346 | |||
| 868b712fbb | |||
| a53b99b39d | |||
| 25d521f013 | |||
| aee0823cb1 | |||
| 2a0e1f4c1f | |||
| ef08c821fa | |||
| 7729dc38f8 |
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
|
||||
@@ -92,8 +92,8 @@ android {
|
||||
applicationId 'mobiledarmasaba.app'
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 6
|
||||
versionName "1.0.2"
|
||||
versionCode 16
|
||||
versionName "2.1.0"
|
||||
}
|
||||
signingConfigs {
|
||||
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">
|
||||
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<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="android:statusBarColor">#ffffff</item>
|
||||
<item name="android:windowOptOutEdgeToEdgeEnforcement" tools:targetApi="35">true</item>
|
||||
</style>
|
||||
<style name="Theme.App.SplashScreen" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenBackground">@color/splashscreen_background</item>
|
||||
|
||||
@@ -4,7 +4,7 @@ export default {
|
||||
expo: {
|
||||
name: "Desa+",
|
||||
slug: "mobile-darmasaba",
|
||||
version: "2.0.5", // Versi aplikasi (App Store)
|
||||
version: "2.1.0", // Versi aplikasi (App Store)
|
||||
jsEngine: "jsc",
|
||||
orientation: "portrait",
|
||||
icon: "./assets/images/logo-icon-small.png",
|
||||
@@ -14,7 +14,7 @@ export default {
|
||||
ios: {
|
||||
supportsTablet: true,
|
||||
bundleIdentifier: "mobiledarmasaba.app",
|
||||
buildNumber: "7",
|
||||
buildNumber: "9",
|
||||
infoPlist: {
|
||||
ITSAppUsesNonExemptEncryption: false,
|
||||
CFBundleDisplayName: "Desa+"
|
||||
@@ -23,7 +23,7 @@ export default {
|
||||
},
|
||||
android: {
|
||||
package: "mobiledarmasaba.app",
|
||||
versionCode: 15,
|
||||
versionCode: 16,
|
||||
adaptiveIcon: {
|
||||
foregroundImage: "./assets/images/logo-icon-small.png",
|
||||
backgroundColor: "#ffffff"
|
||||
|
||||
@@ -3,6 +3,7 @@ import AppHeader from "@/components/AppHeader";
|
||||
import BorderBottomItem from "@/components/borderBottomItem";
|
||||
import Skeleton from "@/components/skeleton";
|
||||
import Text from '@/components/Text';
|
||||
import ErrorView from "@/components/ErrorView";
|
||||
import { ConstEnv } from "@/constants/ConstEnv";
|
||||
import { isImageFile } from "@/constants/FileExtensions";
|
||||
import Styles from "@/constants/Styles";
|
||||
@@ -65,6 +66,7 @@ export default function DetailAnnouncement() {
|
||||
const [loadingOpen, setLoadingOpen] = useState(false)
|
||||
const [preview, setPreview] = useState(false)
|
||||
const [chooseFile, setChooseFile] = useState<FileData>()
|
||||
const [isError, setIsError] = useState(false)
|
||||
|
||||
/**
|
||||
* Opens the image preview modal for the selected image file
|
||||
@@ -79,6 +81,7 @@ export default function DetailAnnouncement() {
|
||||
|
||||
async function handleLoad(loading: boolean) {
|
||||
try {
|
||||
setIsError(false)
|
||||
setLoading(loading)
|
||||
const hasil = await decryptToken(String(token?.current))
|
||||
const response: ApiResponse = await apiGetAnnouncementOne({ id: id, user: hasil })
|
||||
@@ -87,10 +90,12 @@ export default function DetailAnnouncement() {
|
||||
setDataMember(response.member)
|
||||
setDataFile(response.file)
|
||||
} else {
|
||||
setIsError(true)
|
||||
Toast.show({ type: 'small', text1: response.message })
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
setIsError(true)
|
||||
const message = error?.response?.data?.message || "Gagal mengambil data"
|
||||
|
||||
Toast.show({ type: 'small', text1: message })
|
||||
@@ -206,104 +211,110 @@ export default function DetailAnnouncement() {
|
||||
/>
|
||||
}
|
||||
>
|
||||
<View style={[Styles.p15, Styles.mb50]}>
|
||||
<View style={[Styles.wrapPaper, Styles.borderAll, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
|
||||
{
|
||||
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>
|
||||
</>
|
||||
}
|
||||
|
||||
{isError && !loading ? (
|
||||
<View style={[Styles.mv50]}>
|
||||
<ErrorView />
|
||||
</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 style={[Styles.p15, Styles.mb50]}>
|
||||
<View style={[Styles.wrapPaper, Styles.borderAll, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
|
||||
{
|
||||
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>
|
||||
)
|
||||
})
|
||||
:
|
||||
Object.keys(dataMember).map((v: any, i: any) => {
|
||||
return (
|
||||
<View key={i} style={[Styles.mb05]}>
|
||||
<Text style={[Styles.textDefaultSemiBold]}>{dataMember[v]?.[0].group}</Text>
|
||||
<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]}>
|
||||
{
|
||||
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>
|
||||
)
|
||||
})
|
||||
hasHtmlTags(data?.desc) ?
|
||||
<RenderHTML
|
||||
contentWidth={contentWidth}
|
||||
source={{ html: data?.desc }}
|
||||
baseStyle={{ color: colors.text }}
|
||||
/>
|
||||
:
|
||||
<Text>{data?.desc}</Text>
|
||||
}
|
||||
|
||||
</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>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<ImageViewing
|
||||
|
||||
@@ -3,13 +3,14 @@ import Text from "@/components/Text";
|
||||
import ButtonSetting from "@/components/buttonSetting";
|
||||
import DrawerBottom from "@/components/drawerBottom";
|
||||
import Styles from "@/constants/Styles";
|
||||
import { apiRegisteredToken, apiUnregisteredToken } from "@/lib/api";
|
||||
import { checkPermission, getToken, openSettings, requestPermission } from "@/lib/useNotification";
|
||||
import { apiGetCheckToken, apiRegisteredToken, apiUnregisteredToken } from "@/lib/api";
|
||||
import { checkPermission, getToken, openSettings } from "@/lib/useNotification";
|
||||
import { useAuthSession } from "@/providers/AuthProvider";
|
||||
import { useTheme } from "@/providers/ThemeProvider";
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
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 { useSelector } from "react-redux";
|
||||
|
||||
@@ -28,12 +29,14 @@ export default function ListSetting() {
|
||||
|
||||
const [showLogoutModal, setShowLogoutModal] = useState(false)
|
||||
const [showThemeModal, setShowThemeModal] = useState(false)
|
||||
const prevOsPermission = useRef<boolean | undefined>(undefined);
|
||||
|
||||
const registerToken = async () => {
|
||||
try {
|
||||
await AsyncStorage.setItem('@notification_permission', "true");
|
||||
const token = await getToken();
|
||||
if (token) {
|
||||
await apiRegisteredToken({ user: entities.id, token });
|
||||
await apiRegisteredToken({ user: entities.id, token, category: "register" });
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error registering token:', error);
|
||||
@@ -42,9 +45,10 @@ export default function ListSetting() {
|
||||
|
||||
const unregisterToken = async () => {
|
||||
try {
|
||||
await AsyncStorage.setItem('@notification_permission', "false");
|
||||
const token = await getToken();
|
||||
if (token) {
|
||||
await apiUnregisteredToken({ user: entities.id, token });
|
||||
await apiUnregisteredToken({ user: entities.id, token, category: "unregister" });
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error unregistering token:', error);
|
||||
@@ -52,15 +56,31 @@ export default function ListSetting() {
|
||||
};
|
||||
|
||||
const checkNotif = useCallback(async () => {
|
||||
const status = await checkPermission();
|
||||
setIsNotificationEnabled((prev) => {
|
||||
if (prev === false && status === true) {
|
||||
registerToken();
|
||||
} else if (prev === true && status === false) {
|
||||
unregisterToken();
|
||||
const osPermission = await checkPermission();
|
||||
|
||||
// Jika dari tidak diijinkan sistem kemudian diijinkan (setelah balik dari pengaturan device)
|
||||
if (prevOsPermission.current === false && osPermission === true) {
|
||||
await registerToken();
|
||||
}
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -78,10 +98,12 @@ export default function ListSetting() {
|
||||
}, [checkNotif]);
|
||||
|
||||
const handleToggleNotif = async () => {
|
||||
if (isNotificationEnabled) {
|
||||
const osPermission = await checkPermission();
|
||||
|
||||
if (!osPermission) {
|
||||
setModalConfig({
|
||||
title: "Matikan Notifikasi?",
|
||||
message: "Anda akan diarahkan ke pengaturan sistem untuk mematikan notifikasi.",
|
||||
title: "Aktifkan Notifikasi?",
|
||||
message: "Izin notifikasi tidak diberikan. Buka pengaturan sistem untuk mengaktifkannya?",
|
||||
confirmText: "Buka Pengaturan",
|
||||
onConfirm: () => {
|
||||
setModalVisible(false);
|
||||
@@ -90,22 +112,15 @@ export default function ListSetting() {
|
||||
});
|
||||
setModalVisible(true);
|
||||
} else {
|
||||
const granted = await requestPermission();
|
||||
if (granted) {
|
||||
setIsNotificationEnabled(true);
|
||||
registerToken();
|
||||
// OS Permission is granted, perform in-app toggle
|
||||
const targetState = !isNotificationEnabled;
|
||||
if (targetState) {
|
||||
await registerToken();
|
||||
} else {
|
||||
setModalConfig({
|
||||
title: "Aktifkan Notifikasi?",
|
||||
message: "Izin notifikasi tidak diberikan. Buka pengaturan sistem untuk mengaktifkannya?",
|
||||
confirmText: "Buka Pengaturan",
|
||||
onConfirm: () => {
|
||||
setModalVisible(false);
|
||||
openSettings();
|
||||
}
|
||||
});
|
||||
setModalVisible(true);
|
||||
await unregisterToken();
|
||||
}
|
||||
// UI will be updated by checkNotif (triggered by state change or manually here)
|
||||
setIsNotificationEnabled(targetState);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
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 {
|
||||
return Toast.show({ type: 'small', text1: response.message, position: 'bottom' })
|
||||
}
|
||||
} catch (error : any ) {
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
const message = error?.response?.data?.message || "Gagal login"
|
||||
|
||||
|
||||
@@ -7,10 +7,6 @@ import Text from "../Text";
|
||||
export default function ReportChartDocument({ data }: { data: { label: string; value: number; }[] }) {
|
||||
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;
|
||||
|
||||
return (
|
||||
|
||||
@@ -92,7 +92,7 @@ export default function CaraouselHome2({ refreshing }: { refreshing: boolean })
|
||||
backgroundColor: colors.primary
|
||||
},
|
||||
]}
|
||||
resizeMode="cover"
|
||||
resizeMode="stretch"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -20,6 +20,7 @@ export default function ChartDokumenHome({ refreshing }: { refreshing: boolean }
|
||||
const { colors } = useTheme();
|
||||
const [data, setData] = useState<Props>([])
|
||||
const [maxValue, setMaxValue] = useState(5)
|
||||
const [chartKey, setChartKey] = useState(0)
|
||||
const barData = [
|
||||
{ value: 23, label: 'Gambar', frontColor: '#fac858' },
|
||||
{ value: 12, label: 'Dokumen', frontColor: '#92cc76' },
|
||||
@@ -32,8 +33,8 @@ export default function ChartDokumenHome({ refreshing }: { refreshing: boolean }
|
||||
setLoading(loading)
|
||||
const hasil = await decryptToken(String(token?.current))
|
||||
const response = await apiGetDataHome({ cat: "dokumen", user: hasil })
|
||||
const maxValue = response.data.reduce((max: number, obj: { value: number; }) => Math.max(max, obj.value), -Infinity);
|
||||
const roundUp = Math.ceil(maxValue / 10) * 10
|
||||
const maxVal = response.data.reduce((max: number, obj: { value: number; }) => Math.max(max, Number(obj.value)), 0);
|
||||
const roundUp = maxVal > 0 ? Math.ceil(maxVal / 10) * 10 : 10;
|
||||
setMaxValue(roundUp)
|
||||
const convertedArray = response.data.map((item: { color: any; label: any; value: any; }) => ({
|
||||
frontColor: item.color,
|
||||
@@ -41,6 +42,7 @@ export default function ChartDokumenHome({ refreshing }: { refreshing: boolean }
|
||||
value: Number(item.value)
|
||||
}));
|
||||
setData(convertedArray)
|
||||
setChartKey((prev: number) => prev + 1)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
@@ -49,8 +51,9 @@ export default function ChartDokumenHome({ refreshing }: { refreshing: boolean }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (refreshing)
|
||||
if (refreshing) {
|
||||
handleData(false)
|
||||
}
|
||||
}, [refreshing]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -64,6 +67,7 @@ export default function ChartDokumenHome({ refreshing }: { refreshing: boolean }
|
||||
loading ? <Skeleton width={100} height={200} borderRadius={10} widthType="percent" />
|
||||
:
|
||||
<BarChart
|
||||
key={chartKey}
|
||||
showFractionalValues={false}
|
||||
showYAxisIndices
|
||||
noOfSections={4}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Styles from "@/constants/Styles";
|
||||
import { apiGetDataHome } from "@/lib/api";
|
||||
import { useAuthSession } from "@/providers/AuthProvider";
|
||||
import { useTheme } from "@/providers/ThemeProvider";
|
||||
import { router } from "expo-router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Dimensions, View } from "react-native";
|
||||
@@ -10,7 +11,6 @@ import PaperGridContent from "../paperGridContent";
|
||||
import ProgressBar from "../progressBar";
|
||||
import Skeleton from "../skeleton";
|
||||
import Text from "../Text";
|
||||
import { useTheme } from "@/providers/ThemeProvider";
|
||||
|
||||
type Props = {
|
||||
id: string
|
||||
@@ -52,7 +52,7 @@ export default function ProjectHome({ refreshing }: { refreshing: boolean }) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={[Styles.mb15]}>
|
||||
<View style={[Styles.mb05]}>
|
||||
<Text style={[Styles.textDefaultSemiBold, Styles.mb10]}>Kegiatan Terupdate</Text>
|
||||
{
|
||||
loading ? (<Skeleton width={100} height={150} borderRadius={10} widthType="percent" />)
|
||||
@@ -62,7 +62,7 @@ export default function ProjectHome({ refreshing }: { refreshing: boolean }) {
|
||||
ref={ref}
|
||||
style={{ width: "100%" }}
|
||||
width={width * 0.8}
|
||||
height={235}
|
||||
height={220}
|
||||
data={data}
|
||||
loop={false}
|
||||
autoPlay={false}
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function PaperGridContent({ content, children, title, headerColor
|
||||
const bgSource = activeTheme === 'light' ? bgLight : bgDark;
|
||||
return (
|
||||
<Pressable onPress={onPress}>
|
||||
<View style={[content == 'carousel' ? Styles.wrapGridCaraousel : Styles.wrapGridContent]}>
|
||||
<View style={[content == 'carousel' ? Styles.wrapGridCaraousel : Styles.wrapGridContent, { backgroundColor: colors.card }]}>
|
||||
{
|
||||
headerColor == 'warning' ? (
|
||||
<View style={[Styles.headerPaperGrid, ColorsStatus.warning]}>
|
||||
@@ -41,7 +41,6 @@ export default function PaperGridContent({ content, children, title, headerColor
|
||||
}
|
||||
<View style={[
|
||||
contentPosition && contentPosition == 'top' ? Styles.contentPaperGrid2 : Styles.contentPaperGrid,
|
||||
{ backgroundColor: colors.card },
|
||||
height ? { height: height } : {}
|
||||
]}>
|
||||
{children}
|
||||
|
||||
@@ -392,10 +392,10 @@ const Styles = StyleSheet.create({
|
||||
},
|
||||
wrapGridContent: {
|
||||
shadowColor: '#171717',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 10,
|
||||
elevation: 5,
|
||||
shadowRadius: 5,
|
||||
elevation: 2,
|
||||
borderRadius: 5,
|
||||
marginBottom: 15
|
||||
},
|
||||
@@ -403,12 +403,13 @@ const Styles = StyleSheet.create({
|
||||
width: '95%',
|
||||
height: 200,
|
||||
shadowColor: '#171717',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 5,
|
||||
shadowRadius: 5,
|
||||
elevation: 2,
|
||||
borderRadius: 5,
|
||||
marginLeft: 5
|
||||
marginLeft: 5,
|
||||
display: 'flex',
|
||||
},
|
||||
headerPaperGrid: {
|
||||
paddingVertical: 25,
|
||||
@@ -418,15 +419,13 @@ const Styles = StyleSheet.create({
|
||||
borderTopEndRadius: 5
|
||||
},
|
||||
contentPaperGrid: {
|
||||
backgroundColor: 'white',
|
||||
height: 150,
|
||||
height: 125,
|
||||
borderBottomEndRadius: 5,
|
||||
borderBottomStartRadius: 5,
|
||||
paddingHorizontal: 20,
|
||||
justifyContent: 'space-evenly'
|
||||
},
|
||||
contentPaperGrid2: {
|
||||
backgroundColor: 'white',
|
||||
height: 100,
|
||||
borderBottomEndRadius: 5,
|
||||
borderBottomStartRadius: 5,
|
||||
@@ -455,8 +454,8 @@ const Styles = StyleSheet.create({
|
||||
shadowColor: '#171717',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 5,
|
||||
shadowRadius: 5,
|
||||
elevation: 2,
|
||||
},
|
||||
noShadow: {
|
||||
shadowColor: 'transparent',
|
||||
@@ -469,8 +468,8 @@ const Styles = StyleSheet.create({
|
||||
shadowColor: '#171717',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 5,
|
||||
shadowRadius: 5,
|
||||
elevation: 2,
|
||||
},
|
||||
contentItemCenter: {
|
||||
justifyContent: 'center',
|
||||
@@ -491,17 +490,17 @@ const Styles = StyleSheet.create({
|
||||
wrapItemDiscussion: {
|
||||
padding: 15,
|
||||
borderRadius: 5,
|
||||
borderColor: '#d6d8f6',
|
||||
// borderColor: '#d6d8f6',
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
wrapItemBorderBottom: {
|
||||
padding: 10,
|
||||
borderColor: '#d6d8f6',
|
||||
// borderColor: '#d6d8f6',
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
wrapItemBorderAll: {
|
||||
padding: 10,
|
||||
borderColor: '#d6d8f6',
|
||||
// borderColor: '#d6d8f6',
|
||||
borderWidth: 1,
|
||||
borderRadius: 5,
|
||||
marginBottom: 5
|
||||
@@ -749,10 +748,10 @@ const Styles = StyleSheet.create({
|
||||
},
|
||||
wrapHomeCarousel: {
|
||||
shadowColor: '#171717',
|
||||
shadowOffset: { width: 0, height: 5 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 5,
|
||||
elevation: 50,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 8,
|
||||
elevation: 5,
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
@@ -764,7 +763,7 @@ const Styles = StyleSheet.create({
|
||||
width: '80%',
|
||||
borderRadius: 14,
|
||||
overflow: 'hidden',
|
||||
elevation: 5,
|
||||
elevation: 2,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
|
||||
3
eas.json
@@ -28,7 +28,8 @@
|
||||
"buildType": "app-bundle"
|
||||
},
|
||||
"ios": {
|
||||
"simulator": false
|
||||
"simulator": false,
|
||||
"image": "latest"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -394,7 +394,7 @@
|
||||
);
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = mobiledarmasaba.app;
|
||||
PRODUCT_NAME = Desa;
|
||||
PRODUCT_NAME = "Desa";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Desa/Desa-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -429,7 +429,7 @@
|
||||
);
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = mobiledarmasaba.app;
|
||||
PRODUCT_NAME = Desa;
|
||||
PRODUCT_NAME = "Desa";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Desa/Desa-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.0.5</string>
|
||||
<string>2.1.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
@@ -39,7 +39,7 @@
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>7</string>
|
||||
<string>9</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
|
||||
20
lib/api.ts
@@ -13,18 +13,21 @@ export const apiCheckPhoneLogin = async (body: { phone: string }) => {
|
||||
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 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(
|
||||
`${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: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${Constants.expoConfig?.extra?.WA_SERVER_TOKEN}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
number: body.phone,
|
||||
text: message
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
return res.status
|
||||
}
|
||||
|
||||
@@ -740,16 +743,21 @@ export const apiShareDocument = async (data: { dataDivision: any[], dataItem: an
|
||||
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)
|
||||
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)
|
||||
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 }) => {
|
||||
const response = await api.get(`mobile/home/notification?user=${user}&page=${page}`);
|
||||
return response.data;
|
||||
|
||||
@@ -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 {
|
||||
getMessaging,
|
||||
@@ -6,8 +8,7 @@ import {
|
||||
} from '@react-native-firebase/messaging';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { useEffect } from 'react';
|
||||
import { Linking, PermissionsAndroid, Platform } from 'react-native';
|
||||
import { ConstEnv } from '@/constants/ConstEnv';
|
||||
import { Linking, Platform } from 'react-native';
|
||||
|
||||
const RNfirebaseConfig = {
|
||||
apiKey: ConstEnv.firebase.apiKey,
|
||||
@@ -39,13 +40,15 @@ const initializeFirebase = async () => {
|
||||
|
||||
export const checkPermission = async () => {
|
||||
try {
|
||||
if (Platform.OS === 'android') {
|
||||
return await PermissionsAndroid.check(
|
||||
PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS
|
||||
);
|
||||
} else if (Platform.OS === 'ios') {
|
||||
const { status } = await Notifications.getPermissionsAsync();
|
||||
return status === 'granted';
|
||||
// Cek status permission sekarang
|
||||
const { status } = await Notifications.getPermissionsAsync();
|
||||
|
||||
if (status === 'granted') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (status === 'denied') {
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Error checking notification permissions:', err);
|
||||
@@ -63,23 +66,48 @@ export const openSettings = () => {
|
||||
|
||||
export const requestPermission = async () => {
|
||||
try {
|
||||
if (Platform.OS === 'android') {
|
||||
const cek = await PermissionsAndroid.check(
|
||||
PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS
|
||||
);
|
||||
if (!cek) {
|
||||
const granted = await PermissionsAndroid.request(
|
||||
PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS
|
||||
);
|
||||
return granted === PermissionsAndroid.RESULTS.GRANTED;
|
||||
const existing = await AsyncStorage.getItem('@notification_permission');
|
||||
const { status: currentStatus, canAskAgain } = await Notifications.getPermissionsAsync();
|
||||
|
||||
// Jika baru pertama kali (fresh install / storage belum ada)
|
||||
if (existing === null) {
|
||||
// Cek apakah OS memungkinkan untuk memunculkan popup
|
||||
if (currentStatus === 'undetermined' || (currentStatus === 'denied' && canAskAgain)) {
|
||||
const { status: newStatus } = await Notifications.requestPermissionsAsync();
|
||||
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) {
|
||||
console.warn('Error requesting notification permissions:', err);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ConstEnv } from '@/constants/ConstEnv';
|
||||
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 CryptoES from "crypto-es";
|
||||
import { router } from "expo-router";
|
||||
@@ -52,16 +52,12 @@ export default function AuthProvider({ children }: { children: ReactNode }): Rea
|
||||
|
||||
const signIn = useCallback(async (token: string) => {
|
||||
const hasil = await decryptToken(String(token))
|
||||
const permission = await requestPermission()
|
||||
if (permission) {
|
||||
// const permission = await requestPermission()
|
||||
const permissionStorage = await AsyncStorage.getItem('@notification_permission')
|
||||
if (permissionStorage === "true") {
|
||||
const tokenDevice = await getToken()
|
||||
try {
|
||||
// if (Platform.OS === 'android') {
|
||||
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) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
@@ -71,6 +67,7 @@ export default function AuthProvider({ children }: { children: ReactNode }): Rea
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
const register = await apiRegisteredToken({ user: hasil, token: "" })
|
||||
await AsyncStorage.setItem('@token', token);
|
||||
tokenRef.current = token;
|
||||
router.replace('/home')
|
||||
|
||||