Compare commits

...

14 Commits

Author SHA1 Message Date
772551a917 upd: page kosong atau data udah di hapus pada pengumuman 2026-04-17 16:46:49 +08:00
555b9e4037 upd: fix laopran grafik divisi 2026-04-17 15:26:06 +08:00
d4b4db4251 upd: fix: jumlah dokumen grafik pada home 2026-04-17 15:01:14 +08:00
17d92cba25 Merge pull request 'amalia/10-mar-26' (#36) from amalia/10-mar-26 into join
Reviewed-on: #36
2026-03-10 16:30:52 +08:00
e1b62be6da upd:build number 2026-03-10 15:31:57 +08:00
b2b125c410 upd: build eas 2026-03-10 15:19:40 +08:00
1cfecbbdd5 upd : border di android 2026-03-10 11:09:19 +08:00
21006e8eee upd: gambar caraousel 2026-03-10 11:02:26 +08:00
91231d60e4 upd: md 2026-03-09 16:43:04 +08:00
7174e27be1 upd: version dan prebuild 2026-03-09 14:17:36 +08:00
9d4b931aa6 Merge pull request 'amalia/05-mar-26' (#35) from amalia/05-mar-26 into join
Reviewed-on: #35
2026-03-05 16:19:03 +08:00
166d8f1c16 upd: notifikasi
Deskripsi:
- update notifikasi android

No Issues
2026-03-05 14:38:45 +08:00
7060a2d165 upd: otp
Deskripsi:
- link otp baru

NOIssues
2026-03-05 14:15:36 +08:00
d6217aecf1 Merge pull request 'upd: notifikasi' (#34) from amalia/04-mar-26 into join
Reviewed-on: #34
2026-03-04 16:39:05 +08:00
37 changed files with 201 additions and 135 deletions

View File

@@ -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

View File

@@ -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 {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 904 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -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>

View File

@@ -14,7 +14,7 @@ export default {
ios: {
supportsTablet: true,
bundleIdentifier: "mobiledarmasaba.app",
buildNumber: "8",
buildNumber: "9",
infoPlist: {
ITSAppUsesNonExemptEncryption: false,
CFBundleDisplayName: "Desa+"

View File

@@ -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

View File

@@ -33,6 +33,7 @@ export default function ListSetting() {
const registerToken = async () => {
try {
await AsyncStorage.setItem('@notification_permission', "true");
const token = await getToken();
if (token) {
await apiRegisteredToken({ user: entities.id, token, category: "register" });
@@ -44,6 +45,7 @@ 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, category: "unregister" });
@@ -113,10 +115,8 @@ export default function ListSetting() {
// OS Permission is granted, perform in-app toggle
const targetState = !isNotificationEnabled;
if (targetState) {
await AsyncStorage.setItem('@notification_permission', "true");
await registerToken();
} else {
await AsyncStorage.setItem('@notification_permission', "false");
await unregisterToken();
}
// UI will be updated by checkNotif (triggered by state change or manually here)

42
components/ErrorView.tsx Normal file
View 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>
);
}

View File

@@ -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"

View File

@@ -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 (

View File

@@ -92,7 +92,7 @@ export default function CaraouselHome2({ refreshing }: { refreshing: boolean })
backgroundColor: colors.primary
},
]}
resizeMode="cover"
resizeMode="stretch"
/>
)}
/>

View File

@@ -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}

View File

@@ -490,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

View File

@@ -28,7 +28,8 @@
"buildType": "app-bundle"
},
"ios": {
"simulator": false
"simulator": false,
"image": "latest"
}
}
},

View File

@@ -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;

View File

@@ -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>

View File

@@ -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
}

View File

@@ -66,32 +66,41 @@ export const openSettings = () => {
export const requestPermission = async () => {
try {
const { status: currentStatus } = await Notifications.getPermissionsAsync();
const existing = await AsyncStorage.getItem('@notification_permission');
const { status: currentStatus, canAskAgain } = await Notifications.getPermissionsAsync();
// Jika belum pernah ditentukan (undetermined), baru panggil request
if (currentStatus === 'undetermined') {
const { status: newStatus } = await Notifications.requestPermissionsAsync();
await AsyncStorage.setItem('@notification_permission', newStatus === 'granted' ? 'true' : 'false');
return newStatus === 'granted';
// 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;
}
}
// Jika sudah pernah ditentukan (granted/denied), update storage sesuai OS
// Tapi jika granted, kita cek storage dlu sapa tau user pernah matiin manual di app
const osPermission = await checkPermission();
const existing = await AsyncStorage.getItem('@notification_permission');
// 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) {
// Jika OS ijinkan, tapi di storage belum ada, set true
if (existing === null) {
await AsyncStorage.setItem('@notification_permission', 'true');
return true;
}
// Jika OS ijinkan dan di storage ada, ikuti storage (toggle manual user)
return existing === 'true';
}