upd: modal view pengumuman

Deskripsi:
- modal view image pada detail pengumuman

NO Issues
This commit is contained in:
2026-02-03 17:34:14 +08:00
parent e9c11a889d
commit 7d8b72fdfa
3 changed files with 275 additions and 31 deletions

View File

@@ -4,6 +4,7 @@ import BorderBottomItem from "@/components/borderBottomItem";
import Skeleton from "@/components/skeleton";
import Text from '@/components/Text';
import { ConstEnv } from "@/constants/ConstEnv";
import { isImageFile } from "@/constants/FileExtensions";
import Styles from "@/constants/Styles";
import { apiGetAnnouncementOne } from "@/lib/api";
import { useAuthSession } from "@/providers/AuthProvider";
@@ -13,24 +14,46 @@ import { startActivityAsync } from 'expo-intent-launcher';
import { router, Stack, useLocalSearchParams } from "expo-router";
import * as Sharing from 'expo-sharing';
import React, { useEffect, useState } from "react";
import { Alert, Dimensions, Platform, RefreshControl, SafeAreaView, ScrollView, View } from "react-native";
import { Alert, Dimensions, Platform, Pressable, RefreshControl, SafeAreaView, ScrollView, View } from "react-native";
import ImageViewing from 'react-native-image-viewing';
import * as mime from 'react-native-mime-types';
import RenderHTML from 'react-native-render-html';
import Toast from "react-native-toast-message";
import { useSelector } from "react-redux";
type Props = {
id: string
title: string
desc: string
// Define TypeScript interfaces for better type safety
interface AnnouncementData {
id: string;
title: string;
desc: string;
}
interface FileData {
id: string;
idStorage: string;
name: string;
extension: string;
}
interface MemberData {
group: string;
division: string;
}
interface ApiResponse {
success: boolean;
data: AnnouncementData;
member: Record<string, MemberData[]>;
file: FileData[];
message: string;
}
export default function DetailAnnouncement() {
const { id } = useLocalSearchParams<{ id: string }>();
const { token, decryptToken } = useAuthSession()
const [data, setData] = useState<Props>({ id: '', title: '', desc: '' })
const [dataMember, setDataMember] = useState<any>({})
const [dataFile, setDataFile] = useState<{ id: string; idStorage: string; name: string; extension: string }[]>([])
const [data, setData] = useState<AnnouncementData>({ id: '', title: '', desc: '' })
const [dataMember, setDataMember] = useState<Record<string, MemberData[]>>({})
const [dataFile, setDataFile] = useState<FileData[]>([])
const update = useSelector((state: any) => state.announcementUpdate)
const entityUser = useSelector((state: any) => state.user)
const contentWidth = Dimensions.get('window').width
@@ -38,13 +61,24 @@ export default function DetailAnnouncement() {
const arrSkeleton = Array.from({ length: 2 }, (_, index) => index)
const [refreshing, setRefreshing] = useState(false)
const [loadingOpen, setLoadingOpen] = useState(false)
const [preview, setPreview] = useState(false)
const [chooseFile, setChooseFile] = useState<FileData>()
/**
* Opens the image preview modal for the selected image file
* @param item The file data object containing image information
*/
function handleChooseFile(item: FileData) {
setChooseFile(item)
setPreview(true)
}
async function handleLoad(loading: boolean) {
try {
setLoading(loading)
const hasil = await decryptToken(String(token?.current))
const response = await apiGetAnnouncementOne({ id: id, user: hasil })
const response: ApiResponse = await apiGetAnnouncementOne({ id: id, user: hasil })
if (response.success) {
setData(response.data)
setDataMember(response.member)
@@ -68,30 +102,47 @@ export default function DetailAnnouncement() {
handleLoad(true)
}, [])
/**
* Checks if a string contains HTML tags
* @param text The text to check for HTML tags
* @returns True if the text contains HTML tags, false otherwise
*/
function hasHtmlTags(text: string) {
const htmlRegex = /<[a-z][\s\S]*>/i;
return htmlRegex.test(text);
};
}
/**
* Handles pull-to-refresh functionality
* Reloads the announcement data without showing loading indicators
*/
const handleRefresh = async () => {
setRefreshing(true)
handleLoad(false)
// Simulate network request delay for better UX
await new Promise(resolve => setTimeout(resolve, 2000));
setRefreshing(false)
};
const openFile = (item: { idStorage: string; name: string; extension: string }) => {
if (Platform.OS == 'android') setLoadingOpen(true)
let remoteUrl = ConstEnv.url_storage + '/files/' + item.idStorage;
const fileName = item.name + '.' + item.extension;
let localPath = `${FileSystem.documentDirectory}/${fileName}`;
const mimeType = mime.lookup(fileName)
FileSystem.downloadAsync(remoteUrl, localPath).then(async ({ uri }) => {
const contentURL = await FileSystem.getContentUriAsync(uri);
setLoadingOpen(false)
const openFile = async (item: FileData) => {
try {
if (Platform.OS == 'android') {
setLoadingOpen(true);
const remoteUrl = ConstEnv.url_storage + '/files/' + item.idStorage;
const fileName = item.name + '.' + item.extension;
const localPath = `${FileSystem.documentDirectory}/${fileName}`;
const mimeType = mime.lookup(fileName);
// Download the file
const downloadResult = await FileSystem.downloadAsync(remoteUrl, localPath);
if (downloadResult.status !== 200) {
throw new Error(`Download failed with status ${downloadResult.status}`);
}
const contentURL = await FileSystem.getContentUriAsync(downloadResult.uri);
try {
if (Platform.OS === 'android') {
await startActivityAsync(
'android.intent.action.VIEW',
{
@@ -100,15 +151,24 @@ export default function DetailAnnouncement() {
type: mimeType as string,
}
);
} else if (Platform.OS == 'ios') {
Sharing.shareAsync(localPath);
} else if (Platform.OS === 'ios') {
await Sharing.shareAsync(localPath);
}
} catch (openError) {
console.error('Error opening file:', openError);
Alert.alert('INFO', 'Tidak ada aplikasi yang dapat membuka file ini');
}
} catch (error) {
Alert.alert('INFO', 'Gagal membuka file, tidak ada aplikasi yang dapat membuka file ini');
} finally {
if (Platform.OS == 'android') setLoadingOpen(false)
}
console.error('Error downloading or opening file:', error);
Alert.alert('INFO', 'Gagal mengunduh atau membuka file');
Toast.show({
type: 'error',
text1: 'Gagal membuka file',
text2: 'Silakan coba lagi nanti'
});
} finally {
setLoadingOpen(false);
}
};
return (
@@ -183,12 +243,20 @@ export default function DetailAnnouncement() {
</View>
{dataFile.map((item, index) => (
<BorderBottomItem
key={index}
key={`${item.id}-${index}`}
borderType="bottom"
icon={<MaterialCommunityIcons name="file-outline" size={25} color="black" />}
title={item.name}
icon={<MaterialCommunityIcons
name={isImageFile(item.extension) ? "file-image-outline" : "file-document-outline"}
size={25}
color="black"
/>}
title={item.name + '.' + item.extension}
titleWeight="normal"
onPress={() => { openFile({ idStorage: item.idStorage, name: item.name, extension: item.extension }) }}
onPress={() => {
isImageFile(item.extension) ?
handleChooseFile(item)
: openFile(item)
}}
/>
))}
</View>
@@ -229,6 +297,45 @@ export default function DetailAnnouncement() {
</View>
</View>
</ScrollView>
<ImageViewing
images={[{ uri: `${ConstEnv.url_storage}/files/${chooseFile?.idStorage}` }]}
imageIndex={0}
visible={preview}
onRequestClose={() => setPreview(false)}
doubleTapToZoomEnabled
HeaderComponent={({ imageIndex }) => (
<View style={[Styles.headerModalViewImg]}>
{/* CLOSE */}
<Pressable
onPress={() => setPreview(false)}
accessibilityRole="button"
accessibilityLabel="Close image viewer"
>
<Text style={{ color: 'white', fontSize: 26 }}></Text>
</Pressable>
{/* MENU */}
<Pressable
onPress={() => chooseFile && openFile(chooseFile)}
accessibilityRole="button"
accessibilityLabel="Download or share image"
disabled={loadingOpen}
>
<Text style={{ color: loadingOpen ? 'gray' : 'white', fontSize: 22 }}></Text>
</Pressable>
</View>
)}
FooterComponent={({ imageIndex }) => (
<View style={{
paddingBottom: 20,
paddingHorizontal: 16,
alignItems: 'center',
}}>
<Text style={{ color: 'white', fontSize: 16 }}>{chooseFile?.name}.{chooseFile?.extension}</Text>
</View>
)}
/>
</SafeAreaView>
)
}

130
constants/FileExtensions.ts Normal file
View File

@@ -0,0 +1,130 @@
/**
* File Extensions Constants
* Categorizes common file extensions for use throughout the application
*/
// Image file extensions
export const IMAGE_EXTENSIONS = [
'jpg',
'jpeg',
'png',
'gif',
'bmp',
'webp',
'svg',
'tiff',
'ico'
];
// Document file extensions
export const DOCUMENT_EXTENSIONS = [
'pdf',
'doc',
'docx',
'xls',
'xlsx',
'ppt',
'pptx',
'txt',
'rtf',
'odt',
'ods',
'odp',
'csv',
'xml',
'html',
'htm'
];
// Video file extensions
export const VIDEO_EXTENSIONS = [
'mp4',
'avi',
'mov',
'wmv',
'flv',
'webm',
'mkv',
'm4v',
'3gp',
'mpeg',
'mpg'
];
// Audio file extensions
export const AUDIO_EXTENSIONS = [
'mp3',
'wav',
'flac',
'aac',
'ogg',
'wma',
'm4a',
'opus'
];
// Archive file extensions
export const ARCHIVE_EXTENSIONS = [
'zip',
'rar',
'7z',
'tar',
'gz',
'bz2',
'xz',
'iso',
'dmg'
];
// Combined list of all extensions
export const ALL_EXTENSIONS = [
...IMAGE_EXTENSIONS,
...DOCUMENT_EXTENSIONS,
...VIDEO_EXTENSIONS,
...AUDIO_EXTENSIONS,
...ARCHIVE_EXTENSIONS
];
// Helper function to get file type category based on extension
export const getFileTypeCategory = (extension: string): string => {
const ext = extension.toLowerCase();
if (IMAGE_EXTENSIONS.includes(ext)) {
return 'image';
} else if (DOCUMENT_EXTENSIONS.includes(ext)) {
return 'document';
} else if (VIDEO_EXTENSIONS.includes(ext)) {
return 'video';
} else if (AUDIO_EXTENSIONS.includes(ext)) {
return 'audio';
} else if (ARCHIVE_EXTENSIONS.includes(ext)) {
return 'archive';
}
return 'unknown';
};
// Helper function to check if a file is an image
export const isImageFile = (extension: string): boolean => {
return IMAGE_EXTENSIONS.includes(extension.toLowerCase());
};
// Helper function to check if a file is a document
export const isDocumentFile = (extension: string): boolean => {
return DOCUMENT_EXTENSIONS.includes(extension.toLowerCase());
};
// Helper function to check if a file is a video
export const isVideoFile = (extension: string): boolean => {
return VIDEO_EXTENSIONS.includes(extension.toLowerCase());
};
// Helper function to check if a file is audio
export const isAudioFile = (extension: string): boolean => {
return AUDIO_EXTENSIONS.includes(extension.toLowerCase());
};
// Helper function to check if a file is an archive
export const isArchiveFile = (extension: string): boolean => {
return ARCHIVE_EXTENSIONS.includes(extension.toLowerCase());
};

View File

@@ -680,6 +680,13 @@ const Styles = StyleSheet.create({
borderRadius: 10,
padding: 2,
},
headerModalViewImg: {
paddingTop: 50,
paddingHorizontal: 16,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
}
})
export default Styles;