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 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 { isImageFile } from "@/constants/FileExtensions";
import Styles from "@/constants/Styles"; import Styles from "@/constants/Styles";
import { apiGetAnnouncementOne } from "@/lib/api"; import { apiGetAnnouncementOne } from "@/lib/api";
import { useAuthSession } from "@/providers/AuthProvider"; import { useAuthSession } from "@/providers/AuthProvider";
@@ -13,24 +14,46 @@ import { startActivityAsync } from 'expo-intent-launcher';
import { router, Stack, useLocalSearchParams } from "expo-router"; import { router, Stack, useLocalSearchParams } from "expo-router";
import * as Sharing from 'expo-sharing'; import * as Sharing from 'expo-sharing';
import React, { useEffect, useState } from "react"; 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 * as mime from 'react-native-mime-types';
import RenderHTML from 'react-native-render-html'; import RenderHTML from 'react-native-render-html';
import Toast from "react-native-toast-message"; import Toast from "react-native-toast-message";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
type Props = { // Define TypeScript interfaces for better type safety
id: string interface AnnouncementData {
title: string id: string;
desc: 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() { export default function DetailAnnouncement() {
const { id } = useLocalSearchParams<{ id: string }>(); const { id } = useLocalSearchParams<{ id: string }>();
const { token, decryptToken } = useAuthSession() const { token, decryptToken } = useAuthSession()
const [data, setData] = useState<Props>({ id: '', title: '', desc: '' }) const [data, setData] = useState<AnnouncementData>({ id: '', title: '', desc: '' })
const [dataMember, setDataMember] = useState<any>({}) const [dataMember, setDataMember] = useState<Record<string, MemberData[]>>({})
const [dataFile, setDataFile] = useState<{ id: string; idStorage: string; name: string; extension: string }[]>([]) const [dataFile, setDataFile] = useState<FileData[]>([])
const update = useSelector((state: any) => state.announcementUpdate) const update = useSelector((state: any) => state.announcementUpdate)
const entityUser = useSelector((state: any) => state.user) const entityUser = useSelector((state: any) => state.user)
const contentWidth = Dimensions.get('window').width const contentWidth = Dimensions.get('window').width
@@ -38,13 +61,24 @@ export default function DetailAnnouncement() {
const arrSkeleton = Array.from({ length: 2 }, (_, index) => index) const arrSkeleton = Array.from({ length: 2 }, (_, index) => index)
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
const [loadingOpen, setLoadingOpen] = 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) { async function handleLoad(loading: boolean) {
try { try {
setLoading(loading) setLoading(loading)
const hasil = await decryptToken(String(token?.current)) 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) { if (response.success) {
setData(response.data) setData(response.data)
setDataMember(response.member) setDataMember(response.member)
@@ -68,30 +102,47 @@ export default function DetailAnnouncement() {
handleLoad(true) 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) { function hasHtmlTags(text: string) {
const htmlRegex = /<[a-z][\s\S]*>/i; const htmlRegex = /<[a-z][\s\S]*>/i;
return htmlRegex.test(text); return htmlRegex.test(text);
}; }
/**
* Handles pull-to-refresh functionality
* Reloads the announcement data without showing loading indicators
*/
const handleRefresh = async () => { const handleRefresh = async () => {
setRefreshing(true) setRefreshing(true)
handleLoad(false) handleLoad(false)
// Simulate network request delay for better UX
await new Promise(resolve => setTimeout(resolve, 2000)); await new Promise(resolve => setTimeout(resolve, 2000));
setRefreshing(false) setRefreshing(false)
}; };
const openFile = (item: { idStorage: string; name: string; extension: string }) => { const openFile = async (item: FileData) => {
if (Platform.OS == 'android') setLoadingOpen(true) try {
let remoteUrl = ConstEnv.url_storage + '/files/' + item.idStorage; setLoadingOpen(true);
const fileName = item.name + '.' + item.extension; const remoteUrl = ConstEnv.url_storage + '/files/' + item.idStorage;
let localPath = `${FileSystem.documentDirectory}/${fileName}`; const fileName = item.name + '.' + item.extension;
const mimeType = mime.lookup(fileName) 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);
FileSystem.downloadAsync(remoteUrl, localPath).then(async ({ uri }) => {
const contentURL = await FileSystem.getContentUriAsync(uri);
setLoadingOpen(false)
try { try {
if (Platform.OS == 'android') { if (Platform.OS === 'android') {
await startActivityAsync( await startActivityAsync(
'android.intent.action.VIEW', 'android.intent.action.VIEW',
{ {
@@ -100,15 +151,24 @@ export default function DetailAnnouncement() {
type: mimeType as string, type: mimeType as string,
} }
); );
} else if (Platform.OS == 'ios') { } else if (Platform.OS === 'ios') {
Sharing.shareAsync(localPath); await Sharing.shareAsync(localPath);
} }
} catch (error) { } catch (openError) {
Alert.alert('INFO', 'Gagal membuka file, tidak ada aplikasi yang dapat membuka file ini'); console.error('Error opening file:', openError);
} finally { Alert.alert('INFO', 'Tidak ada aplikasi yang dapat membuka file ini');
if (Platform.OS == 'android') setLoadingOpen(false)
} }
}); } catch (error) {
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 ( return (
@@ -183,12 +243,20 @@ export default function DetailAnnouncement() {
</View> </View>
{dataFile.map((item, index) => ( {dataFile.map((item, index) => (
<BorderBottomItem <BorderBottomItem
key={index} key={`${item.id}-${index}`}
borderType="bottom" borderType="bottom"
icon={<MaterialCommunityIcons name="file-outline" size={25} color="black" />} icon={<MaterialCommunityIcons
title={item.name} name={isImageFile(item.extension) ? "file-image-outline" : "file-document-outline"}
size={25}
color="black"
/>}
title={item.name + '.' + item.extension}
titleWeight="normal" titleWeight="normal"
onPress={() => { openFile({ idStorage: item.idStorage, name: item.name, extension: item.extension }) }} onPress={() => {
isImageFile(item.extension) ?
handleChooseFile(item)
: openFile(item)
}}
/> />
))} ))}
</View> </View>
@@ -229,6 +297,45 @@ export default function DetailAnnouncement() {
</View> </View>
</View> </View>
</ScrollView> </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> </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, borderRadius: 10,
padding: 2, padding: 2,
}, },
headerModalViewImg: {
paddingTop: 50,
paddingHorizontal: 16,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
}
}) })
export default Styles; export default Styles;