feat: 章节软删除、对话左滑删除,移除已读状态
- 章节:详情页增加删除按钮,软删除(is_active=False),AI 不再修改但保留供参考 - 章节:get_chapter 增加 is_active 校验,已删除章节返回 404 - 章节:AI 生成时参考同类别已删除章节摘要 - 对话:左滑显示删除,调用 hard delete API,删除前二次确认 - 对话:根布局包裹 GestureHandlerRootView 以支持 Swipeable - 对话:移除已读/未读状态展示及相关 i18n
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useLocalSearchParams } from 'expo-router';
|
||||
import { Settings, X } from 'lucide-react-native';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import { Settings, Trash2, X } from 'lucide-react-native';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
@@ -20,7 +21,7 @@ import { Icon } from '@/components/ui/icon';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { ScreenHeader } from '@/components/screen-header';
|
||||
import { ScreenGutter } from '@/constants/layout';
|
||||
import { useChapterDetail } from '@/features/memoir/hooks';
|
||||
import { useChapterDetail, useDeleteChapter } from '@/features/memoir/hooks';
|
||||
|
||||
// Life-Echo reading colors (from HTML reference)
|
||||
const READING_COLORS = {
|
||||
@@ -536,6 +537,7 @@ export default function ChapterScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation('memoir');
|
||||
const { data: chapter, isLoading } = useChapterDetail(id ?? '');
|
||||
const deleteChapter = useDeleteChapter();
|
||||
|
||||
const [settingsVisible, setSettingsVisible] = useState(false);
|
||||
const [fontSize, setFontSize] = useState<FontSize>('default');
|
||||
@@ -568,6 +570,7 @@ export default function ChapterScreen() {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: bgColor,
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
@@ -576,6 +579,25 @@ export default function ChapterScreen() {
|
||||
>
|
||||
{t('chapterReading.chapterNotFound')}
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => router.back()}
|
||||
style={({ pressed }) => ({
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 12,
|
||||
backgroundColor: READING_COLORS.primary,
|
||||
opacity: pressed ? 0.8 : 1,
|
||||
})}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('chapterReading.back')}
|
||||
>
|
||||
<Text
|
||||
style={{ color: '#fff', fontSize: 16, fontWeight: '600' }}
|
||||
selectable={false}
|
||||
>
|
||||
{t('chapterReading.back')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -583,6 +605,25 @@ export default function ChapterScreen() {
|
||||
const sections = chapter.sections ?? [];
|
||||
const coverImageUrl = chapter.cover_image?.url ?? null;
|
||||
|
||||
const handleDeletePress = () => {
|
||||
Alert.alert(
|
||||
t('chapterReading.deleteChapter'),
|
||||
t('chapterReading.confirmDeleteMessage'),
|
||||
[
|
||||
{ text: t('chapterReading.cancel'), style: 'cancel' },
|
||||
{
|
||||
text: t('chapterReading.deleteChapterAction'),
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
deleteChapter.mutate(chapter.id, {
|
||||
onSuccess: () => router.back(),
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: bgColor }}>
|
||||
<ScreenHeader
|
||||
@@ -592,19 +633,38 @@ export default function ChapterScreen() {
|
||||
title={chapter.title}
|
||||
backAccessibilityLabel={t('chapterReading.back')}
|
||||
right={
|
||||
<Pressable
|
||||
onPress={() => setSettingsVisible(true)}
|
||||
style={({ pressed }) => ({
|
||||
padding: 8,
|
||||
marginRight: -8,
|
||||
borderRadius: 9999,
|
||||
opacity: pressed ? 0.7 : 1,
|
||||
})}
|
||||
accessibilityLabel={t('chapterReading.settings')}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Icon as={Settings} size={24} color={READING_COLORS.primary} />
|
||||
</Pressable>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
|
||||
<Pressable
|
||||
onPress={handleDeletePress}
|
||||
disabled={deleteChapter.isPending}
|
||||
style={({ pressed }) => ({
|
||||
padding: 8,
|
||||
borderRadius: 9999,
|
||||
opacity: pressed ? 0.7 : 1,
|
||||
})}
|
||||
accessibilityLabel={t('chapterReading.deleteChapter')}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Icon
|
||||
as={Trash2}
|
||||
size={22}
|
||||
color={READING_COLORS.onSurfaceVariant}
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => setSettingsVisible(true)}
|
||||
style={({ pressed }) => ({
|
||||
padding: 8,
|
||||
marginRight: -8,
|
||||
borderRadius: 9999,
|
||||
opacity: pressed ? 0.7 : 1,
|
||||
})}
|
||||
accessibilityLabel={t('chapterReading.settings')}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Icon as={Settings} size={24} color={READING_COLORS.primary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import React from 'react';
|
||||
import { Alert, Pressable, ScrollView, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CheckCheck, MessageCirclePlus, User } from 'lucide-react-native';
|
||||
import { Swipeable } from 'react-native-gesture-handler';
|
||||
import { MessageCirclePlus, Trash2, User } from 'lucide-react-native';
|
||||
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
@@ -12,6 +13,7 @@ import { NetworkError } from '@/core/api/types';
|
||||
import {
|
||||
useConversations,
|
||||
useCreateConversation,
|
||||
useDeleteConversation,
|
||||
} from '@/features/conversation/hooks';
|
||||
import type { ConversationListItem } from '@/features/conversation/types';
|
||||
|
||||
@@ -65,43 +67,13 @@ function GreetingCardSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({
|
||||
status,
|
||||
label,
|
||||
}: {
|
||||
status: 'read' | 'unread';
|
||||
label: string;
|
||||
}) {
|
||||
const isPrimary = status === 'unread';
|
||||
return (
|
||||
<View className="flex-row items-center gap-1.5">
|
||||
{status === 'read' && (
|
||||
<Icon as={CheckCheck} className="text-primary" size={14} />
|
||||
)}
|
||||
{status === 'unread' && (
|
||||
<View className="h-2 w-2 rounded-full bg-primary" />
|
||||
)}
|
||||
<Text
|
||||
className={`text-xs font-semibold uppercase tracking-wider ${
|
||||
isPrimary ? 'text-primary' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ConversationCard({
|
||||
item,
|
||||
statusLabel,
|
||||
onPress,
|
||||
}: {
|
||||
item: ConversationListItem;
|
||||
statusLabel: string;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const status = item.unreadCount > 0 ? ('unread' as const) : ('read' as const);
|
||||
const avatarBg = item.isDefaultAssistant ? 'bg-primary' : 'bg-secondary';
|
||||
const avatarIconClass = item.isDefaultAssistant
|
||||
? 'text-primary-foreground'
|
||||
@@ -136,14 +108,56 @@ function ConversationCard({
|
||||
>
|
||||
{item.latestMessagePreview || ''}
|
||||
</Text>
|
||||
<View className="mt-2">
|
||||
<StatusBadge status={status} label={statusLabel} />
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function SwipeableConversationCard({
|
||||
item,
|
||||
onPress,
|
||||
}: {
|
||||
item: ConversationListItem;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation('conversation');
|
||||
const deleteConversation = useDeleteConversation();
|
||||
|
||||
const handleDelete = () => {
|
||||
Alert.alert(t('deleteConversation'), t('confirmDeleteConversation'), [
|
||||
{ text: t('cancel'), style: 'cancel' },
|
||||
{
|
||||
text: t('delete'),
|
||||
style: 'destructive',
|
||||
onPress: () => deleteConversation.mutate(item.id),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const renderRightActions = () => (
|
||||
<Pressable
|
||||
onPress={handleDelete}
|
||||
disabled={deleteConversation.isPending}
|
||||
className="w-20 items-center justify-center rounded-xl bg-destructive active:opacity-80"
|
||||
style={{ marginLeft: 4 }}
|
||||
>
|
||||
<Icon as={Trash2} className="text-destructive-foreground" size={24} />
|
||||
<Text
|
||||
className="mt-1 text-xs font-semibold text-destructive-foreground"
|
||||
selectable={false}
|
||||
>
|
||||
{t('delete')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
return (
|
||||
<Swipeable renderRightActions={renderRightActions} friction={2}>
|
||||
<ConversationCard item={item} onPress={onPress} />
|
||||
</Swipeable>
|
||||
);
|
||||
}
|
||||
|
||||
const SKELETON_COUNT = 3;
|
||||
|
||||
export default function ConversationsScreen() {
|
||||
@@ -277,12 +291,9 @@ export default function ConversationsScreen() {
|
||||
</View>
|
||||
<View className="gap-4">
|
||||
{conversations.map((item) => (
|
||||
<ConversationCard
|
||||
<SwipeableConversationCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
statusLabel={
|
||||
item.unreadCount > 0 ? t('unread') : t('read')
|
||||
}
|
||||
onPress={() => handleConversationPress(item.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { PortalHost } from '@rn-primitives/portal';
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import React, { useEffect } from 'react';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import {
|
||||
SafeAreaProvider,
|
||||
initialWindowMetrics,
|
||||
@@ -43,23 +44,25 @@ export default function RootLayout() {
|
||||
}, [setColorScheme]);
|
||||
|
||||
return (
|
||||
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
||||
<AppProviders>
|
||||
<TypographyProvider>
|
||||
<ThemeVariablesProvider>
|
||||
<NavigationThemeProvider>
|
||||
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
|
||||
<AnimatedSplashOverlay />
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen name="(auth)" />
|
||||
<Stack.Screen name="(main)" />
|
||||
</Stack>
|
||||
<PortalHost />
|
||||
</NavigationThemeProvider>
|
||||
</ThemeVariablesProvider>
|
||||
</TypographyProvider>
|
||||
</AppProviders>
|
||||
</SafeAreaProvider>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
||||
<AppProviders>
|
||||
<TypographyProvider>
|
||||
<ThemeVariablesProvider>
|
||||
<NavigationThemeProvider>
|
||||
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
|
||||
<AnimatedSplashOverlay />
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen name="(auth)" />
|
||||
<Stack.Screen name="(main)" />
|
||||
</Stack>
|
||||
<PortalHost />
|
||||
</NavigationThemeProvider>
|
||||
</ThemeVariablesProvider>
|
||||
</TypographyProvider>
|
||||
</AppProviders>
|
||||
</SafeAreaProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ interface Resources {
|
||||
profile: 'Profile';
|
||||
};
|
||||
theme: {
|
||||
brand: 'Brand';
|
||||
default: 'Default';
|
||||
};
|
||||
};
|
||||
@@ -64,17 +63,22 @@ interface Resources {
|
||||
conversation: {
|
||||
addMore: 'More';
|
||||
agentName: 'Life Echo';
|
||||
cancel: 'Cancel';
|
||||
cancelRecording: 'Cancel recording';
|
||||
chatTitle: 'Conversation';
|
||||
confirm: 'OK';
|
||||
confirmDeleteConversation: 'Are you sure you want to delete this conversation? It cannot be recovered.';
|
||||
connectionConnected: 'Connected';
|
||||
connectionConnecting: 'Connecting...';
|
||||
connectionDisconnected: 'Disconnected';
|
||||
createError: 'Unable to create conversation. Please check your network and try again.';
|
||||
delete: 'Delete';
|
||||
deleteConversation: 'Delete Conversation';
|
||||
emptyGreetingSubtitle: 'Chat with Echo and record your stories.';
|
||||
greetingTitle: 'Say Hello';
|
||||
inputPlaceholder: 'Type a message...';
|
||||
inputPlaceholderVoice: 'Type here or hold the mic to speak...';
|
||||
me: 'Me';
|
||||
read: 'Read';
|
||||
recentChats: 'Recent Chats';
|
||||
recordingPermissionDenied: 'Microphone permission is required to record';
|
||||
send: 'Send';
|
||||
@@ -83,8 +87,8 @@ interface Resources {
|
||||
switchToVoice: 'Switch to voice input';
|
||||
tapToEndRecording: 'Tap to end';
|
||||
tapToStartRecording: 'Tap to start recording';
|
||||
unread: 'Unread';
|
||||
viewAll: 'View All';
|
||||
voiceMessagePreview: 'Voice message';
|
||||
};
|
||||
explore: {};
|
||||
home: {};
|
||||
@@ -95,8 +99,12 @@ interface Resources {
|
||||
backgroundColor: 'Background';
|
||||
bgPureWhite: 'White';
|
||||
bgSepia: 'Sepia';
|
||||
cancel: 'Cancel';
|
||||
chapterNotFound: 'Chapter not found';
|
||||
close: 'Close';
|
||||
confirmDeleteMessage: 'Are you sure you want to delete this chapter? You will no longer be able to view it, but the content will be kept for future reference.';
|
||||
deleteChapter: 'Delete Chapter';
|
||||
deleteChapterAction: 'Delete';
|
||||
fontSans: 'Sans';
|
||||
fontSerif: 'Serif';
|
||||
fontSize: 'Font Size';
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"confirmDeleteConversation": "Are you sure you want to delete this conversation? It cannot be recovered.",
|
||||
"createError": "Unable to create conversation. Please check your network and try again.",
|
||||
"confirm": "OK",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"deleteConversation": "Delete Conversation",
|
||||
"addMore": "More",
|
||||
"agentName": "Life Echo",
|
||||
"cancelRecording": "Cancel recording",
|
||||
@@ -13,7 +17,6 @@
|
||||
"inputPlaceholder": "Type a message...",
|
||||
"inputPlaceholderVoice": "Type here or hold the mic to speak...",
|
||||
"me": "Me",
|
||||
"read": "Read",
|
||||
"recentChats": "Recent Chats",
|
||||
"recordingPermissionDenied": "Microphone permission is required to record",
|
||||
"send": "Send",
|
||||
@@ -22,7 +25,6 @@
|
||||
"switchToVoice": "Switch to voice input",
|
||||
"tapToEndRecording": "Tap to end",
|
||||
"tapToStartRecording": "Tap to start recording",
|
||||
"unread": "Unread",
|
||||
"viewAll": "View All",
|
||||
"voiceMessagePreview": "Voice message"
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
"chapterLabel": "Chapter {{index}}",
|
||||
"chapterReading": {
|
||||
"back": "Back",
|
||||
"cancel": "Cancel",
|
||||
"confirmDeleteMessage": "Are you sure you want to delete this chapter? You will no longer be able to view it, but the content will be kept for future reference.",
|
||||
"deleteChapter": "Delete Chapter",
|
||||
"deleteChapterAction": "Delete",
|
||||
"backgroundColor": "Background",
|
||||
"bgPureWhite": "White",
|
||||
"bgSepia": "Sepia",
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"confirmDeleteConversation": "确定要删除此对话吗?删除后无法恢复。",
|
||||
"createError": "无法创建对话,请检查网络连接或稍后重试",
|
||||
"confirm": "知道了",
|
||||
"cancel": "取消",
|
||||
"delete": "删除",
|
||||
"deleteConversation": "删除对话",
|
||||
"addMore": "更多功能",
|
||||
"agentName": "岁月知己",
|
||||
"cancelRecording": "取消录音发送",
|
||||
@@ -13,7 +17,6 @@
|
||||
"inputPlaceholder": "输入消息...",
|
||||
"inputPlaceholderVoice": "点击这里输入,或者按住左边说话...",
|
||||
"me": "我",
|
||||
"read": "已读",
|
||||
"recentChats": "最近对话",
|
||||
"recordingPermissionDenied": "需要麦克风权限才能录音",
|
||||
"send": "发送",
|
||||
@@ -22,7 +25,6 @@
|
||||
"switchToVoice": "切换到语音输入",
|
||||
"tapToEndRecording": "点击结束",
|
||||
"tapToStartRecording": "点击开始录音",
|
||||
"unread": "未读",
|
||||
"viewAll": "查看全部",
|
||||
"voiceMessagePreview": "语音消息"
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
"chapterLabel": "第 {{index}} 章",
|
||||
"chapterReading": {
|
||||
"back": "返回",
|
||||
"cancel": "取消",
|
||||
"confirmDeleteMessage": "确定要删除本章节吗?删除后您将无法再查看,但内容会保留供后续参考。",
|
||||
"deleteChapter": "删除章节",
|
||||
"deleteChapterAction": "删除",
|
||||
"backgroundColor": "背景色",
|
||||
"bgPureWhite": "白色",
|
||||
"bgSepia": "护眼",
|
||||
|
||||
Reference in New Issue
Block a user