Skip to content

Commit 978f412

Browse files
committed
✨ feat: 新增单节课程分享并统一分享模块
1 parent 31b9674 commit 978f412

15 files changed

Lines changed: 348 additions & 25 deletions

File tree

app/(pages)/settings/course/add.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from "react-native";
1616
import Toast from "react-native-toast-message";
1717

18+
import { CourseShareSheet } from "@/components/share/course-share-sheet";
1819
import { ConfirmSheet } from "@/components/ui/confirm-sheet";
1920
import { ScrollLockProvider, useScrollLock } from "@/components/ui/scroll-lock";
2021
import { WEEKDAY_KEYS as DAY_KEYS } from "@/constants/weekdays";
@@ -144,6 +145,7 @@ export default function AddEditCourseScreen() {
144145
isEdit && existingRecords.length > 0 ? null : 0,
145146
);
146147
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
148+
const [shareVisible, setShareVisible] = useState(false);
147149
// 拖动周次网格或节次滑块时临时禁用页面滚动,避免手势冲突
148150
const [scrollEnabled, setScrollEnabled] = useState(true);
149151
const scrollLockCount = useRef(0);
@@ -397,6 +399,18 @@ export default function AddEditCourseScreen() {
397399
</Text>
398400
</Pressable>
399401

402+
{isEdit && (
403+
<Pressable
404+
onPress={() => setShareVisible(true)}
405+
className="mt-3 flex-row items-center justify-center rounded-xl bg-white py-3.5 active:bg-neutral-50 dark:bg-neutral-800 dark:active:bg-neutral-700"
406+
>
407+
<Ionicons name="qr-code-outline" size={18} color="#3b82f6" />
408+
<Text className="ml-1.5 text-base font-medium text-blue-500">
409+
{t("schedule.shareCourse")}
410+
</Text>
411+
</Pressable>
412+
)}
413+
400414
{isEdit && (
401415
<Pressable
402416
onPress={() => setShowDeleteConfirm(true)}
@@ -422,6 +436,11 @@ export default function AddEditCourseScreen() {
422436
destructive
423437
onConfirm={handleDelete}
424438
/>
439+
440+
<CourseShareSheet
441+
courseName={shareVisible ? (editName ?? null) : null}
442+
onClose={() => setShareVisible(false)}
443+
/>
425444
</>
426445
);
427446
}

app/(pages)/settings/course/palette.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { useMemo, useState } from "react";
55
import { Pressable, ScrollView, Text, View } from "react-native";
66
import Toast from "react-native-toast-message";
77

8-
import { ScanCodeSheet } from "@/components/scan/scan-code-sheet";
8+
import { ShareSheet } from "@/components/share/share-sheet";
99
import { BottomSheet } from "@/components/ui/bottom-sheet";
1010
import { MenuGroup, MenuItem } from "@/components/ui/menu-item";
1111
import {
@@ -20,7 +20,6 @@ import { useT } from "@/lib/i18n";
2020
import {
2121
buildSchedulePaletteScanEnvelope,
2222
buildShareableSchedulePalette,
23-
createScanUrl,
2423
resolveScanAction,
2524
SCHEDULE_PALETTE_SCAN_TYPE,
2625
} from "@/lib/scan";
@@ -306,13 +305,12 @@ export default function PaletteScreen() {
306305
</View>
307306
</BottomSheet>
308307

309-
<ScanCodeSheet
308+
<ShareSheet
310309
visible={shareVisible}
311310
onClose={() => setShareVisible(false)}
312311
title={t("palette.shareCodeTitle")}
313312
description={t("palette.shareCodeDesc")}
314-
qrValue={JSON.stringify(shareEnvelope)}
315-
shareValue={createScanUrl(shareEnvelope)}
313+
envelope={shareEnvelope}
316314
/>
317315
</>
318316
);

app/(tabs)/index.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import Animated, { useAnimatedStyle } from "react-native-reanimated";
1515
import { SafeAreaView } from "react-native-safe-area-context";
1616

1717
import { CourseDetailModal } from "@/components/layout/course-detail-modal";
18+
import { CourseShareSheet } from "@/components/share/course-share-sheet";
1819
import { HomeMenu } from "@/components/layout/home-menu";
1920
import { TabBackground } from "@/components/layout/tab-background";
2021
import { AnnouncementBanner } from "@/components/ui/announcement-banner";
@@ -276,6 +277,7 @@ export default function HomeScreen() {
276277

277278
const [activeTab, setActiveTab] = useState(() => (allTodayFinished ? 1 : 0));
278279
const [selectedCourse, setSelectedCourse] = useState<Course | null>(null);
280+
const [shareName, setShareName] = useState<string | null>(null);
279281

280282
const openCourseDetail = useCallback(
281283
(course: Course) => {
@@ -293,6 +295,15 @@ export default function HomeScreen() {
293295
});
294296
}, []);
295297

298+
const handleShareCourse = useCallback(
299+
(course: Course) => {
300+
haptic();
301+
setSelectedCourse(null);
302+
setShareName(course.name);
303+
},
304+
[haptic],
305+
);
306+
296307
const pagerRef = useRef<PagerView>(null);
297308
const [initialPage] = useState(() => (allTodayFinished ? 1 : 0));
298309
const { position, handler: pagerScrollHandler } =
@@ -700,6 +711,12 @@ export default function HomeScreen() {
700711
}
701712
onClose={() => setSelectedCourse(null)}
702713
onEdit={handleEditCourse}
714+
onShare={handleShareCourse}
715+
/>
716+
717+
<CourseShareSheet
718+
courseName={shareName}
719+
onClose={() => setShareName(null)}
703720
/>
704721
</View>
705722
);

components/layout/course-detail-modal.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,23 @@ import type { Course } from "@/store/course";
1414

1515
/**
1616
* 课程详情弹窗,课表页与首页共用。
17-
* 操作按钮按需显示:传入 onEdit / onAddAtSameSlot 才渲染对应按钮。
17+
* 操作按钮按需显示:传入 onEdit / onShare / onAddAtSameSlot 才渲染对应按钮。
1818
*/
1919
export function CourseDetailModal({
2020
course,
2121
headerColor,
2222
showOtherWeekTag = false,
2323
onClose,
2424
onEdit,
25+
onShare,
2526
onAddAtSameSlot,
2627
}: {
2728
course: Course | null;
2829
headerColor: string;
2930
showOtherWeekTag?: boolean;
3031
onClose: () => void;
3132
onEdit?: (course: Course) => void;
33+
onShare?: (course: Course) => void;
3234
onAddAtSameSlot?: (course: Course) => void;
3335
}) {
3436
const t = useT();
@@ -178,7 +180,7 @@ export function CourseDetailModal({
178180
/>
179181
</View>
180182

181-
{(onEdit || onAddAtSameSlot) && (
183+
{(onEdit || onShare || onAddAtSameSlot) && (
182184
<View
183185
style={{
184186
flexDirection: "row",
@@ -191,15 +193,23 @@ export function CourseDetailModal({
191193
{onEdit && (
192194
<DetailActionButton
193195
icon="create-outline"
194-
label={t("schedule.editCourse")}
196+
label={t("common.edit")}
195197
isDark={isDark}
196198
onPress={() => onEdit(course)}
197199
/>
198200
)}
201+
{onShare && (
202+
<DetailActionButton
203+
icon="qr-code-outline"
204+
label={t("common.share")}
205+
isDark={isDark}
206+
onPress={() => onShare(course)}
207+
/>
208+
)}
199209
{onAddAtSameSlot && (
200210
<DetailActionButton
201211
icon="add-circle-outline"
202-
label={t("schedule.addAtSameSlot")}
212+
label={t("common.add")}
203213
isDark={isDark}
204214
onPress={() => onAddAtSameSlot(course)}
205215
/>

components/layout/schedule.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
View,
2222
} from "react-native";
2323

24+
import { CourseShareSheet } from "@/components/share/course-share-sheet";
2425
import { WEEKDAY_KEYS } from "@/constants/weekdays";
2526
import { MAX_WEEK } from "@/lib/course-weeks";
2627
import { useColorScheme } from "@/hooks/use-color-scheme";
@@ -392,6 +393,7 @@ export function Schedule({
392393
const blurTarget = useAndroidBlurTarget();
393394
const [selected, setSelected] = useState<Course | null>(null);
394395
const [slotCourses, setSlotCourses] = useState<Course[] | null>(null);
396+
const [shareName, setShareName] = useState<string | null>(null);
395397
const [quickAddSlot, setQuickAddSlot] = useState<QuickAddSlot | null>(null);
396398

397399
// 周末模式下的横向滚动容器
@@ -584,6 +586,13 @@ export function Schedule({
584586
});
585587
};
586588

589+
const handleShareCourse = (course: Course) => {
590+
haptic();
591+
setSelected(null);
592+
setSlotCourses(null);
593+
setShareName(course.name);
594+
};
595+
587596
const openQuickAddForCourse = (course: Course) => {
588597
// 始终用完整分组对齐,避免在 compact 模式下(layout.groups 不含 6/7/13 节)
589598
// 课程 sectionStart 找不到分组导致"添加课程"按钮静默失效。
@@ -815,9 +824,15 @@ export function Schedule({
815824
showOtherWeekTag={!!selected && !isInCurrentWeek(selected)}
816825
onClose={() => setSelected(null)}
817826
onEdit={handleEditCourse}
827+
onShare={handleShareCourse}
818828
onAddAtSameSlot={openQuickAddForCourse}
819829
/>
820830

831+
<CourseShareSheet
832+
courseName={shareName}
833+
onClose={() => setShareName(null)}
834+
/>
835+
821836
<Modal
822837
visible={!!slotCourses}
823838
transparent
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { useMemo } from "react";
2+
3+
import { ShareSheet } from "@/components/share/share-sheet";
4+
import { useT } from "@/lib/i18n";
5+
import { buildCourseSingleScanEnvelope } from "@/lib/scan";
6+
import { useCourseStore } from "@/store/course";
7+
8+
export function CourseShareSheet({
9+
courseName,
10+
onClose,
11+
}: Readonly<{
12+
courseName: string | null;
13+
onClose: () => void;
14+
}>) {
15+
const t = useT();
16+
const courses = useCourseStore((s) => s.courses);
17+
18+
const envelope = useMemo(() => {
19+
if (!courseName) return null;
20+
const records = courses.filter((c) => c.name === courseName);
21+
if (records.length === 0) return null;
22+
return buildCourseSingleScanEnvelope(records);
23+
}, [courseName, courses]);
24+
25+
if (!envelope) return null;
26+
27+
return (
28+
<ShareSheet
29+
visible
30+
onClose={onClose}
31+
title={t("scan.courseShareTitle")}
32+
description={t("scan.courseShareDesc")}
33+
envelope={envelope}
34+
/>
35+
);
36+
}
Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as Clipboard from "expo-clipboard";
2-
import { useEffect, useState } from "react";
2+
import { useEffect, useMemo, useState } from "react";
33
import { ActivityIndicator, Pressable, Share, Text, View } from "react-native";
44
import QRCode from "qrcode";
55
import { SvgXml } from "react-native-svg";
@@ -8,28 +8,52 @@ import Toast from "react-native-toast-message";
88
import { BottomSheet } from "@/components/ui/bottom-sheet";
99
import { useHaptics } from "@/hooks/use-haptics";
1010
import { useT } from "@/lib/i18n";
11-
import { QR_SAFE_BYTE_LIMIT, utf8ByteLength } from "@/lib/scan";
11+
import {
12+
QR_SAFE_BYTE_LIMIT,
13+
type ScanEnvelope,
14+
utf8ByteLength,
15+
} from "@/lib/scan";
16+
import { buildShareArtifacts, resolveShortLink } from "@/lib/share";
1217

13-
export function ScanCodeSheet({
18+
export function ShareSheet({
1419
visible,
1520
onClose,
1621
title,
1722
description,
18-
qrValue,
19-
shareValue,
23+
envelope,
2024
}: Readonly<{
2125
visible: boolean;
2226
onClose: () => void;
2327
title: string;
2428
description: string;
25-
qrValue: string;
26-
shareValue?: string;
29+
envelope: ScanEnvelope;
2730
}>) {
2831
const t = useT();
2932
const haptic = useHaptics();
30-
const link = shareValue ?? qrValue;
33+
34+
const { qrValue, deepLink } = useMemo(
35+
() => buildShareArtifacts(envelope),
36+
[envelope],
37+
);
3138
const tooLarge = utf8ByteLength(qrValue) > QR_SAFE_BYTE_LIMIT;
3239

40+
const [shortLink, setShortLink] = useState<string | null>(null);
41+
const link = shortLink ?? deepLink;
42+
43+
useEffect(() => {
44+
if (!visible) return;
45+
let cancelled = false;
46+
setShortLink(null);
47+
resolveShortLink(envelope)
48+
.then((url) => {
49+
if (!cancelled) setShortLink(url);
50+
})
51+
.catch(() => {});
52+
return () => {
53+
cancelled = true;
54+
};
55+
}, [envelope, visible]);
56+
3357
const [qrResult, setQrResult] = useState<{
3458
value: string;
3559
svg: string | null;
@@ -69,7 +93,7 @@ export function ScanCodeSheet({
6993
await Clipboard.setStringAsync(link);
7094
Toast.show({
7195
type: "success",
72-
text1: t("scan.codeCopied"),
96+
text1: t("share.codeCopied"),
7397
position: "bottom",
7498
});
7599
};
@@ -89,13 +113,13 @@ export function ScanCodeSheet({
89113
<View className="h-64 w-64 items-center justify-center rounded-2xl bg-white p-3">
90114
{tooLarge ? (
91115
<Text className="px-2 text-center text-sm leading-5 text-neutral-500">
92-
{t("scan.codeTooLarge")}
116+
{t("share.codeTooLarge")}
93117
</Text>
94118
) : qrSvg ? (
95119
<SvgXml xml={qrSvg} width={232} height={232} />
96120
) : failed ? (
97121
<Text className="text-center text-sm text-red-500">
98-
{t("scan.codeRenderFailed")}
122+
{t("share.codeRenderFailed")}
99123
</Text>
100124
) : (
101125
<ActivityIndicator />
@@ -109,15 +133,15 @@ export function ScanCodeSheet({
109133
onPress={copy}
110134
>
111135
<Text className="text-base font-medium text-neutral-600 dark:text-neutral-300">
112-
{t("scan.copyCode")}
136+
{t("share.copyCode")}
113137
</Text>
114138
</Pressable>
115139
<Pressable
116140
className="flex-1 items-center rounded-xl bg-blue-500 py-3 active:bg-blue-600"
117141
onPress={share}
118142
>
119143
<Text className="text-base font-medium text-white">
120-
{t("scan.shareCode")}
144+
{t("share.shareCode")}
121145
</Text>
122146
</Pressable>
123147
</View>

0 commit comments

Comments
 (0)