- 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
328 lines
15 KiB
TypeScript
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>
|
|
)
|
|
}
|