Files
mobile-darmasaba/app/(application)/announcement/[id].tsx
amaliadwiy 0cb085caa8 feat: redesign halaman pengumuman dan pindahkan styles ke announcement.styles.ts
- Redesign list, detail, create, dan edit pengumuman menggunakan pola sectionCard
- Buat constants/styles/announcement.styles.ts untuk class announcementList* dan announcementDetail*
- Hapus local StyleSheet S dari index.tsx dan [id].tsx, ganti dengan Styles global
- Tambah getFileIcon/getFileColor helper dan fileGrid berwarna per tipe file
- Sesuaikan edit/[id].tsx dengan pola design create.tsx
2026-05-19 14:27:29 +08:00

328 lines
15 KiB
TypeScript

import HeaderRightAnnouncementDetail from "@/components/announcement/headerAnnouncementDetail";
import AppHeader from "@/components/AppHeader";
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";
import { apiGetAnnouncementOne } from "@/lib/api";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
import * as FileSystem from 'expo-file-system';
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 { 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";
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 { colors } = useTheme();
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 - 62
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [loadingOpen, setLoadingOpen] = useState(false)
const [preview, setPreview] = useState(false)
const [chooseFile, setChooseFile] = useState<FileData>()
const [isError, setIsError] = useState(false)
const themed = {
background: { backgroundColor: colors.background },
card: { backgroundColor: colors.card, borderColor: colors.icon + '18' },
iconBox: { backgroundColor: colors.icon + '18' },
sectionLabel: { color: colors.dimmed },
titleText: { color: colors.text },
fileChipBorder: { borderColor: colors.icon + '30' },
fileChipPressed: { backgroundColor: colors.icon + '10' },
groupSeparator: { borderTopColor: colors.icon + '18' },
divisionIconBg: { backgroundColor: colors.icon + '15' },
}
function handleChooseFile(item: FileData) {
setChooseFile(item)
setPreview(true)
}
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 })
if (response.success) {
setData(response.data)
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 })
} finally {
setLoading(false)
}
}
useEffect(() => { handleLoad(false) }, [update])
useEffect(() => { handleLoad(true) }, [])
function hasHtmlTags(text: string) {
return /<[a-z][\s\S]*>/i.test(text);
}
const handleRefresh = async () => {
setRefreshing(true)
handleLoad(false)
await new Promise(resolve => setTimeout(resolve, 2000));
setRefreshing(false)
};
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);
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', {
data: contentURL,
flags: 1,
type: mimeType as string,
});
} else if (Platform.OS === 'ios') {
await Sharing.shareAsync(localPath);
}
} catch {
Toast.show({ type: 'error', text1: 'Tidak ada aplikasi yang dapat membuka file ini' });
}
} catch {
Toast.show({ type: 'error', text1: 'Gagal membuka file', text2: 'Silakan coba lagi nanti' });
} finally {
setLoadingOpen(false);
}
};
return (
<SafeAreaView style={[Styles.flex1, themed.background]}>
<Stack.Screen
options={{
header: () => (
<AppHeader
title="Pengumuman"
showBack={true}
onPressLeft={() => router.back()}
right={entityUser.role != 'user' && entityUser.role != 'coadmin'
? <HeaderRightAnnouncementDetail id={id} />
: <></>
}
/>
)
}}
/>
<ScrollView
showsVerticalScrollIndicator={false}
style={[Styles.flex1, themed.background]}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} tintColor={colors.icon} />
}
>
{isError && !loading ? (
<View style={Styles.mv50}>
<ErrorView />
</View>
) : (
<View style={Styles.announcementDetailContainer}>
{/* Title + Description */}
<View style={[Styles.wrapPaper, Styles.noShadow, Styles.sectionCard, Styles.announcementDetailCard, themed.card]}>
{loading ? (
<View style={Styles.announcementDetailSkeletonGap}>
<View style={[Styles.rowItemsCenter, Styles.announcementDetailSkeletonIconRow]}>
<Skeleton width={38} height={38} borderRadius={10} />
<Skeleton width={60} widthType="percent" height={16} borderRadius={6} />
</View>
<Skeleton width={100} widthType="percent" height={10} borderRadius={6} />
<Skeleton width={100} widthType="percent" height={10} borderRadius={6} />
<Skeleton width={80} widthType="percent" height={10} borderRadius={6} />
</View>
) : (
<>
<View style={[Styles.rowItemsCenter, Styles.announcementDetailTitleRow]}>
<View style={[Styles.sectionIconBox, Styles.announcementDetailIconBox, themed.iconBox]}>
<MaterialIcons name="campaign" size={22} color={colors.icon} />
</View>
<Text style={[Styles.textDefaultSemiBold, Styles.announcementDetailTitleText, themed.titleText]} numberOfLines={2}>
{data.title}
</Text>
</View>
{hasHtmlTags(data.desc)
? <RenderHTML
contentWidth={contentWidth}
source={{ html: data.desc }}
baseStyle={{ color: colors.text }}
/>
: <Text style={Styles.textDefault}>{data.desc}</Text>
}
</>
)}
</View>
{/* Files */}
{dataFile.length > 0 && (
<View>
<View style={[Styles.rowItemsCenter, Styles.announcementDetailSectionLabelRow]}>
<MaterialCommunityIcons name="paperclip" size={14} color={colors.dimmed} />
<Text style={[Styles.textInformation, themed.sectionLabel]}>
Lampiran ({dataFile.length})
</Text>
</View>
<View style={[Styles.wrapPaper, Styles.noShadow, Styles.sectionCard, Styles.announcementDetailCard, Styles.announcementDetailFileCardPadding, themed.card]}>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View style={[Styles.rowItemsCenter, Styles.announcementDetailFileChipList]}>
{dataFile.map((item, index) => (
<Pressable
key={`${item.id}-${index}`}
onPress={() => isImageFile(item.extension) ? handleChooseFile(item) : openFile(item)}
style={({ pressed }) => [Styles.announcementDetailFileChip, themed.fileChipBorder,
pressed ? themed.fileChipPressed : themed.background]}
>
<MaterialCommunityIcons
name={isImageFile(item.extension) ? "file-image-outline" : "file-document-outline"}
size={16}
color={colors.icon}
/>
<Text style={[Styles.textInformation, Styles.announcementDetailFileChipText, themed.titleText]} numberOfLines={1}>
{item.name}.{item.extension}
</Text>
</Pressable>
))}
</View>
</ScrollView>
</View>
</View>
)}
{/* Recipients */}
<View>
<View style={[Styles.rowItemsCenter, Styles.announcementDetailSectionLabelRow]}>
<MaterialIcons name="groups" size={14} color={colors.dimmed} />
<Text style={[Styles.textInformation, themed.sectionLabel]}>
Ditujukan Kepada
</Text>
</View>
<View style={[Styles.wrapPaper, Styles.noShadow, Styles.sectionCard, Styles.announcementDetailCard, themed.card]}>
{loading ? (
<View style={Styles.announcementDetailRecipientGap}>
<Skeleton width={40} widthType="percent" height={10} borderRadius={6} />
<Skeleton width={100} widthType="percent" height={10} borderRadius={6} />
<Skeleton width={60} widthType="percent" height={10} borderRadius={6} />
<Skeleton width={100} widthType="percent" height={10} borderRadius={6} />
</View>
) : (
Object.keys(dataMember).map((v, i) => (
<View key={i} style={i > 0 ? [Styles.announcementDetailGroupSeparator, themed.groupSeparator] : undefined}>
<Text style={[Styles.textInformation, Styles.announcementDetailGroupLabel, themed.sectionLabel]}>
{dataMember[v]?.[0].group}
</Text>
<View>
{dataMember[v].map((item, x) => (
<View key={x} style={[Styles.rowItemsCenter, Styles.announcementDetailDivisionRow]}>
<View style={[Styles.announcementDetailDivisionIconCircle, themed.divisionIconBg]}>
<MaterialIcons name="group" size={14} color={colors.icon} />
</View>
<Text style={[Styles.textDefault, Styles.flex1, themed.titleText]}>
{item.division}
</Text>
</View>
))}
</View>
</View>
))
)}
</View>
</View>
</View>
)}
</ScrollView>
<ImageViewing
images={[{ uri: `${ConstEnv.url_storage}/files/${chooseFile?.idStorage}` }]}
imageIndex={0}
visible={preview}
onRequestClose={() => setPreview(false)}
doubleTapToZoomEnabled
HeaderComponent={() => (
<View style={Styles.headerModalViewImg}>
<Pressable onPress={() => setPreview(false)} accessibilityRole="button">
<Text style={[Styles.textWhite, Styles.font26]}></Text>
</Pressable>
<Pressable
onPress={() => chooseFile && openFile(chooseFile)}
accessibilityRole="button"
disabled={loadingOpen}
>
<Text style={[Styles.font26, { color: loadingOpen ? 'gray' : 'white' }]}></Text>
</Pressable>
</View>
)}
FooterComponent={() => (
<View style={[Styles.pb20, Styles.ph16, Styles.alignCenter]}>
<Text style={[Styles.textWhite, Styles.font16]}>{chooseFile?.name}.{chooseFile?.extension}</Text>
</View>
)}
/>
</SafeAreaView>
)
}