From e9c11a889d6e3ee7ecb692f1fedb5499e9117fc8 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Tue, 3 Feb 2026 16:46:06 +0800 Subject: [PATCH 1/2] fix: tampilan pengumuman Deskripsi: - jarak bawah pada detail pengumuman No Issues --- app/(application)/announcement/[id].tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/(application)/announcement/[id].tsx b/app/(application)/announcement/[id].tsx index 49ec413..f9b9822 100644 --- a/app/(application)/announcement/[id].tsx +++ b/app/(application)/announcement/[id].tsx @@ -1,7 +1,6 @@ import HeaderRightAnnouncementDetail from "@/components/announcement/headerAnnouncementDetail"; import AppHeader from "@/components/AppHeader"; import BorderBottomItem from "@/components/borderBottomItem"; -import ButtonBackHeader from "@/components/buttonBackHeader"; import Skeleton from "@/components/skeleton"; import Text from '@/components/Text'; import { ConstEnv } from "@/constants/ConstEnv"; @@ -139,7 +138,7 @@ export default function DetailAnnouncement() { /> } > - + { loading ? From 7d8b72fdfab52824ee11f49976575658d2dace37 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Tue, 3 Feb 2026 17:34:14 +0800 Subject: [PATCH 2/2] upd: modal view pengumuman Deskripsi: - modal view image pada detail pengumuman NO Issues --- app/(application)/announcement/[id].tsx | 169 +++++++++++++++++++----- constants/FileExtensions.ts | 130 ++++++++++++++++++ constants/Styles.ts | 7 + 3 files changed, 275 insertions(+), 31 deletions(-) create mode 100644 constants/FileExtensions.ts diff --git a/app/(application)/announcement/[id].tsx b/app/(application)/announcement/[id].tsx index f9b9822..404fa14 100644 --- a/app/(application)/announcement/[id].tsx +++ b/app/(application)/announcement/[id].tsx @@ -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; + file: FileData[]; + message: string; } export default function DetailAnnouncement() { const { id } = useLocalSearchParams<{ id: string }>(); const { token, decryptToken } = useAuthSession() - const [data, setData] = useState({ id: '', title: '', desc: '' }) - const [dataMember, setDataMember] = useState({}) - const [dataFile, setDataFile] = useState<{ id: string; idStorage: string; name: string; extension: string }[]>([]) + const [data, setData] = useState({ id: '', title: '', desc: '' }) + const [dataMember, setDataMember] = useState>({}) + const [dataFile, setDataFile] = useState([]) 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() + + /** + * 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) + const openFile = async (item: FileData) => { + try { + 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); - FileSystem.downloadAsync(remoteUrl, localPath).then(async ({ uri }) => { - const contentURL = await FileSystem.getContentUriAsync(uri); - setLoadingOpen(false) try { - if (Platform.OS == 'android') { + 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 (error) { - Alert.alert('INFO', 'Gagal membuka file, tidak ada aplikasi yang dapat membuka file ini'); - } finally { - if (Platform.OS == 'android') setLoadingOpen(false) + } catch (openError) { + console.error('Error opening file:', openError); + Alert.alert('INFO', 'Tidak ada aplikasi yang dapat membuka file ini'); } - }); + } 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 ( @@ -183,12 +243,20 @@ export default function DetailAnnouncement() { {dataFile.map((item, index) => ( } - title={item.name} + icon={} + 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) + }} /> ))} @@ -229,6 +297,45 @@ export default function DetailAnnouncement() { + + setPreview(false)} + doubleTapToZoomEnabled + HeaderComponent={({ imageIndex }) => ( + + {/* CLOSE */} + setPreview(false)} + accessibilityRole="button" + accessibilityLabel="Close image viewer" + > + + + + {/* MENU */} + chooseFile && openFile(chooseFile)} + accessibilityRole="button" + accessibilityLabel="Download or share image" + disabled={loadingOpen} + > + + + + )} + FooterComponent={({ imageIndex }) => ( + + {chooseFile?.name}.{chooseFile?.extension} + + )} + /> ) } \ No newline at end of file diff --git a/constants/FileExtensions.ts b/constants/FileExtensions.ts new file mode 100644 index 0000000..72f7332 --- /dev/null +++ b/constants/FileExtensions.ts @@ -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()); +}; \ No newline at end of file diff --git a/constants/Styles.ts b/constants/Styles.ts index 1bc5a43..c7abe89 100644 --- a/constants/Styles.ts +++ b/constants/Styles.ts @@ -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; \ No newline at end of file