Merge branch 'main' into damer
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"expo": {
|
"expo": {
|
||||||
"name": "Hey Mama",
|
"name": "client",
|
||||||
"slug": "hey-mama",
|
"slug": "client",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "heymama",
|
"scheme": "client",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"newArchEnabled": true,
|
"newArchEnabled": true,
|
||||||
"splash": {
|
"splash": {
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
},
|
},
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"bundleIdentifier": "com.heymama.app"
|
"bundleIdentifier": "com.anonymous.client"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useLayoutEffect, useMemo, useState, useCallback, useRef } from 'react';
|
import { useEffect, useLayoutEffect, useMemo, useState, useCallback, useRef } from 'react';
|
||||||
import { StyleSheet, View, Dimensions, Text, Pressable, PanResponder, Animated as RNAnimated } from 'react-native';
|
import { StyleSheet, View, Dimensions, Text, Pressable, PanResponder, Animated as RNAnimated, ImageBackground } from 'react-native';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigation, useFocusEffect } from 'expo-router';
|
import { useNavigation, useFocusEffect } from 'expo-router';
|
||||||
import Animated, {
|
import Animated, {
|
||||||
@@ -39,6 +39,40 @@ import LikeIcon from '@/assets/images/icon/like_icon.svg';
|
|||||||
|
|
||||||
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||||
|
|
||||||
|
// 预定义风景图列表
|
||||||
|
const NATURE_IMAGES = [
|
||||||
|
require('@/assets/theme/nature/1.png'),
|
||||||
|
require('@/assets/theme/nature/2.png'),
|
||||||
|
require('@/assets/theme/nature/3.png'),
|
||||||
|
require('@/assets/theme/nature/4.png'),
|
||||||
|
require('@/assets/theme/nature/5.png'),
|
||||||
|
require('@/assets/theme/nature/6.png'),
|
||||||
|
require('@/assets/theme/nature/7.png'),
|
||||||
|
require('@/assets/theme/nature/8.png'),
|
||||||
|
require('@/assets/theme/nature/9.png'),
|
||||||
|
require('@/assets/theme/nature/10.png'),
|
||||||
|
require('@/assets/theme/nature/11.png'),
|
||||||
|
require('@/assets/theme/nature/12.png'),
|
||||||
|
require('@/assets/theme/nature/13.png'),
|
||||||
|
require('@/assets/theme/nature/14.png'),
|
||||||
|
require('@/assets/theme/nature/15.png'),
|
||||||
|
require('@/assets/theme/nature/17.png'),
|
||||||
|
require('@/assets/theme/nature/18.png'),
|
||||||
|
require('@/assets/theme/nature/19.png'),
|
||||||
|
require('@/assets/theme/nature/20.png'),
|
||||||
|
require('@/assets/theme/nature/22.png'),
|
||||||
|
];
|
||||||
|
|
||||||
|
// 预定义颜色列表
|
||||||
|
const THEME_COLORS = [
|
||||||
|
'#F7D9BF',
|
||||||
|
'#CBF2D8',
|
||||||
|
'#F5CDDE',
|
||||||
|
'#F2ECCB',
|
||||||
|
'#E2CBF2',
|
||||||
|
'#CBD9F2',
|
||||||
|
];
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
@@ -126,12 +160,26 @@ export default function HomeScreen() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const backgroundColor = themeMode === 'color' ? '#F3D0E1' : '#F4D6C2';
|
const backgroundColor = useMemo(() => {
|
||||||
|
if (themeMode === 'color') {
|
||||||
|
const colorIndex = Math.floor(index / 10) % THEME_COLORS.length;
|
||||||
|
return THEME_COLORS[colorIndex];
|
||||||
|
}
|
||||||
|
return '#F4D6C2'; // 风景模式下的默认底色(图片加载前显示)
|
||||||
|
}, [themeMode, index]);
|
||||||
|
|
||||||
|
// 计算当前应该显示的风景图索引(滑动 10 次切换一张)
|
||||||
|
const natureImageIndex = useMemo(() => {
|
||||||
|
return Math.floor(index / 10) % NATURE_IMAGES.length;
|
||||||
|
}, [index]);
|
||||||
|
|
||||||
|
const currentNatureImage = NATURE_IMAGES[natureImageIndex];
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerStyle: { backgroundColor },
|
headerStyle: { backgroundColor: themeMode === 'scenery' ? 'transparent' : backgroundColor },
|
||||||
|
headerTransparent: themeMode === 'scenery',
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<View style={styles.headerRight}>
|
<View style={styles.headerRight}>
|
||||||
<CircleIconButton
|
<CircleIconButton
|
||||||
@@ -149,7 +197,7 @@ export default function HomeScreen() {
|
|||||||
</View>
|
</View>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}, [backgroundColor, navigation, t]);
|
}, [backgroundColor, themeMode, navigation, t]);
|
||||||
|
|
||||||
const textAnimatedStyle = useAnimatedStyle(() => ({
|
const textAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
transform: [{ translateY: translateY.value }],
|
transform: [{ translateY: translateY.value }],
|
||||||
@@ -241,10 +289,11 @@ export default function HomeScreen() {
|
|||||||
|
|
||||||
// 2. 保存到收藏夹,包含当前背景信息
|
// 2. 保存到收藏夹,包含当前背景信息
|
||||||
await addFavorite({
|
await addFavorite({
|
||||||
id: typeof currentContentId === 'number' ? String(currentContentId) : (item as any).id,
|
favId: String(Date.now()), // 生成唯一 ID
|
||||||
|
id: item.id,
|
||||||
date: dateStr,
|
date: dateStr,
|
||||||
themeMode: themeMode,
|
themeMode: themeMode,
|
||||||
background: backgroundColor, // 目前存储的是颜色值
|
background: themeMode === 'scenery' ? String(natureImageIndex) : backgroundColor,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. 爱心缩放动画
|
// 3. 爱心缩放动画
|
||||||
@@ -267,8 +316,15 @@ export default function HomeScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor }]} {...panResponder.panHandlers}>
|
<View style={[styles.container, { backgroundColor }]} {...panResponder.panHandlers}>
|
||||||
<Animated.View style={[styles.card, textAnimatedStyle]}>
|
{themeMode === 'scenery' && (
|
||||||
<Text style={styles.text}>{item.text}</Text>
|
<ImageBackground
|
||||||
|
source={currentNatureImage}
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Animated.View style={[styles.card, textAnimatedStyle, themeMode === 'scenery' && styles.sceneryCard]}>
|
||||||
|
<Text style={[styles.text, themeMode === 'scenery' && styles.sceneryText]}>{item.text}</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
@@ -281,9 +337,13 @@ export default function HomeScreen() {
|
|||||||
style={styles.reactionInner}
|
style={styles.reactionInner}
|
||||||
>
|
>
|
||||||
{likeFilled ? (
|
{likeFilled ? (
|
||||||
<LikeFilledIcon width={35} height={36} />
|
<LikeFilledIcon width={35} height={36} style={{ color: '#EA6969' }} />
|
||||||
) : (
|
) : (
|
||||||
<LikeIcon width={35} height={36} />
|
<LikeIcon
|
||||||
|
width={35}
|
||||||
|
height={36}
|
||||||
|
style={{ color: themeMode === 'scenery' ? '#FFFFFF' : '#5E2A28' }}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -342,9 +402,15 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
paddingHorizontal: 30,
|
position: 'absolute',
|
||||||
alignItems: 'center',
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 30,
|
||||||
|
zIndex: 5, // 降低层级,防止遮挡底部按钮
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
fontSize: 22,
|
fontSize: 22,
|
||||||
@@ -353,6 +419,16 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
|
sceneryCard: {
|
||||||
|
// 风景模式下稍微收窄文案宽度,增加呼吸感
|
||||||
|
paddingHorizontal: 50,
|
||||||
|
},
|
||||||
|
sceneryText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
textShadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
textShadowOffset: { width: 0, height: 1 },
|
||||||
|
textShadowRadius: 4,
|
||||||
|
},
|
||||||
actions: {
|
actions: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: SCREEN_HEIGHT * 0.16,
|
bottom: SCREEN_HEIGHT * 0.16,
|
||||||
@@ -360,6 +436,7 @@ const styles = StyleSheet.create({
|
|||||||
right: 0,
|
right: 0,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
zIndex: 20, // 提升层级,确保在最顶层可点击
|
||||||
},
|
},
|
||||||
reactionButton: {
|
reactionButton: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -140,14 +140,13 @@ export default function OnboardingScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSkip = () => {
|
const onSkip = async () => {
|
||||||
// 跳过整个 Onboarding:仍生成一个“全跳过”的最小画像,保证下游可用
|
// 跳过整个 Onboarding:仍生成一个“全跳过”的最小画像,保证下游可用
|
||||||
const scoringProfile = buildUserProfileFromQuestionnaire({});
|
const scoringProfile = buildUserProfileFromQuestionnaire({});
|
||||||
void setUserProfileScoring(scoringProfile);
|
await setUserProfileScoring(scoringProfile);
|
||||||
|
|
||||||
// 标记已完成,避免下次启动再次进入 Onboarding
|
// 标记已完成,避免下次启动再次进入 Onboarding
|
||||||
void setOnboardingCompleted(true);
|
await setOnboardingCompleted(true);
|
||||||
|
|
||||||
router.replace('/(app)/home');
|
router.replace('/(app)/home');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { ActivityIndicator, StyleSheet, View } from 'react-native';
|
import { ActivityIndicator, StyleSheet, View } from 'react-native';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
||||||
|
|
||||||
import { getOnboardingCompleted, getConsentAccepted, setOnboardingCompleted, setConsentAccepted } from '@/src/storage/appStorage';
|
import { getOnboardingCompleted, getConsentAccepted } from '@/src/storage/appStorage';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动分发:根据 consent 和 onboarding 状态跳转
|
* 启动分发:根据 consent 和 onboarding 状态跳转
|
||||||
@@ -14,9 +13,6 @@ export default function Index() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
// 注意:不要在启动时无条件清空存储,否则 Onboarding/画像等数据无法持久化。
|
|
||||||
// 如需调试重置,请在开发期手动清空或自行加调试开关。
|
|
||||||
|
|
||||||
// 1. 检查是否同意协议
|
// 1. 检查是否同意协议
|
||||||
const consentAccepted = await getConsentAccepted();
|
const consentAccepted = await getConsentAccepted();
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -26,9 +22,17 @@ export default function Index() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 检查 Onboarding
|
// 2. 检查 Onboarding 是否已完成
|
||||||
const completed = await getOnboardingCompleted();
|
const completed = await getOnboardingCompleted();
|
||||||
router.replace(completed ? '/(app)/home' : '/(onboarding)/onboarding');
|
if (cancelled) return;
|
||||||
|
|
||||||
|
if (completed) {
|
||||||
|
// 如果已经完成过流程,直接进 Home
|
||||||
|
router.replace('/(app)/home');
|
||||||
|
} else {
|
||||||
|
// 如果是首次进入(或未完成流程),进入 Onboarding
|
||||||
|
router.replace('/(onboarding)/onboarding');
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
@@ -45,4 +49,3 @@ export default function Index() {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
container: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
BIN
client/assets/theme/nature/1.png
Normal file
|
After Width: | Height: | Size: 358 KiB |
BIN
client/assets/theme/nature/10.png
Normal file
|
After Width: | Height: | Size: 629 KiB |
BIN
client/assets/theme/nature/11.png
Normal file
|
After Width: | Height: | Size: 532 KiB |
BIN
client/assets/theme/nature/12.png
Normal file
|
After Width: | Height: | Size: 127 KiB |
BIN
client/assets/theme/nature/13.png
Normal file
|
After Width: | Height: | Size: 449 KiB |
BIN
client/assets/theme/nature/14.png
Normal file
|
After Width: | Height: | Size: 525 KiB |
BIN
client/assets/theme/nature/15.png
Normal file
|
After Width: | Height: | Size: 693 KiB |
BIN
client/assets/theme/nature/17.png
Normal file
|
After Width: | Height: | Size: 458 KiB |
BIN
client/assets/theme/nature/18.png
Normal file
|
After Width: | Height: | Size: 593 KiB |
BIN
client/assets/theme/nature/19.png
Normal file
|
After Width: | Height: | Size: 414 KiB |
BIN
client/assets/theme/nature/2.png
Normal file
|
After Width: | Height: | Size: 381 KiB |
BIN
client/assets/theme/nature/20.png
Normal file
|
After Width: | Height: | Size: 461 KiB |
BIN
client/assets/theme/nature/22.png
Normal file
|
After Width: | Height: | Size: 606 KiB |
BIN
client/assets/theme/nature/3.png
Normal file
|
After Width: | Height: | Size: 384 KiB |
BIN
client/assets/theme/nature/4.png
Normal file
|
After Width: | Height: | Size: 388 KiB |
BIN
client/assets/theme/nature/5.png
Normal file
|
After Width: | Height: | Size: 236 KiB |
BIN
client/assets/theme/nature/6.png
Normal file
|
After Width: | Height: | Size: 721 KiB |
BIN
client/assets/theme/nature/7.png
Normal file
|
After Width: | Height: | Size: 358 KiB |
BIN
client/assets/theme/nature/8.png
Normal file
|
After Width: | Height: | Size: 415 KiB |
BIN
client/assets/theme/nature/9.png
Normal file
|
After Width: | Height: | Size: 214 KiB |
@@ -50,6 +50,29 @@ type Props = {
|
|||||||
type Page = 'root' | 'favorites' | 'dailyReminder' | 'widget' | 'language' | 'widgetHowTo';
|
type Page = 'root' | 'favorites' | 'dailyReminder' | 'widget' | 'language' | 'widgetHowTo';
|
||||||
type NavDirection = 'forward' | 'back';
|
type NavDirection = 'forward' | 'back';
|
||||||
|
|
||||||
|
const NATURE_IMAGES = [
|
||||||
|
require('@/assets/theme/nature/1.png'),
|
||||||
|
require('@/assets/theme/nature/2.png'),
|
||||||
|
require('@/assets/theme/nature/3.png'),
|
||||||
|
require('@/assets/theme/nature/4.png'),
|
||||||
|
require('@/assets/theme/nature/5.png'),
|
||||||
|
require('@/assets/theme/nature/6.png'),
|
||||||
|
require('@/assets/theme/nature/7.png'),
|
||||||
|
require('@/assets/theme/nature/8.png'),
|
||||||
|
require('@/assets/theme/nature/9.png'),
|
||||||
|
require('@/assets/theme/nature/10.png'),
|
||||||
|
require('@/assets/theme/nature/11.png'),
|
||||||
|
require('@/assets/theme/nature/12.png'),
|
||||||
|
require('@/assets/theme/nature/13.png'),
|
||||||
|
require('@/assets/theme/nature/14.png'),
|
||||||
|
require('@/assets/theme/nature/15.png'),
|
||||||
|
require('@/assets/theme/nature/17.png'),
|
||||||
|
require('@/assets/theme/nature/18.png'),
|
||||||
|
require('@/assets/theme/nature/19.png'),
|
||||||
|
require('@/assets/theme/nature/20.png'),
|
||||||
|
require('@/assets/theme/nature/22.png'),
|
||||||
|
];
|
||||||
|
|
||||||
export default function ProfileModal({ visible, name: propName, onClose }: Props) {
|
export default function ProfileModal({ visible, name: propName, onClose }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -260,11 +283,11 @@ function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) {
|
|||||||
setFavorites(list);
|
setFavorites(list);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRemove(id: string) {
|
async function handleRemove(favId: string) {
|
||||||
// 1. 调用存储层移除收藏
|
// 1. 调用存储层移除收藏
|
||||||
await removeFavorite(id);
|
await removeFavorite(favId);
|
||||||
// 2. 更新本地状态
|
// 2. 更新本地状态
|
||||||
setFavorites(prev => prev.filter(item => item.id !== id));
|
setFavorites(prev => prev.filter(item => item.favId !== favId));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -274,7 +297,7 @@ function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) {
|
|||||||
) : (
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
data={favorites}
|
data={favorites}
|
||||||
keyExtractor={(it) => it.id}
|
keyExtractor={(it) => it.favId}
|
||||||
contentContainerStyle={styles.favList}
|
contentContainerStyle={styles.favList}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
@@ -290,11 +313,30 @@ function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) {
|
|||||||
<View style={styles.favRight}>
|
<View style={styles.favRight}>
|
||||||
<View style={[
|
<View style={[
|
||||||
styles.favThumb,
|
styles.favThumb,
|
||||||
{ backgroundColor: item.background } // 动态同步 Home 页的背景
|
item.themeMode === 'scenery' ? {} : { backgroundColor: item.background }
|
||||||
]}>
|
]}>
|
||||||
<Text style={styles.favThumbText} numberOfLines={4}>{item.text}</Text>
|
{item.themeMode === 'scenery' ? (
|
||||||
|
<View style={StyleSheet.absoluteFill}>
|
||||||
|
<Image
|
||||||
|
source={NATURE_IMAGES[parseInt(item.background)]}
|
||||||
|
style={{
|
||||||
|
width: width * 0.6,
|
||||||
|
height: 800, // 假设原图较高,设置一个较大的高度
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0, // 关键:将图片底部对齐容器底部
|
||||||
|
}}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
<Text style={[
|
||||||
|
styles.favThumbText,
|
||||||
|
item.themeMode === 'scenery' && { color: '#FFFFFF', textShadowColor: 'rgba(0,0,0,0.5)', textShadowOffset: {width:0, height:1}, textShadowRadius: 3 }
|
||||||
|
]} numberOfLines={4}>
|
||||||
|
{item.text}
|
||||||
|
</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => handleRemove(item.id)}
|
onPress={() => handleRemove(item.favId)}
|
||||||
style={styles.favRemoveBtn}
|
style={styles.favRemoveBtn}
|
||||||
hitSlop={10}
|
hitSlop={10}
|
||||||
>
|
>
|
||||||
@@ -765,6 +807,7 @@ const styles = StyleSheet.create({
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: 'rgba(119, 47, 0, 0.05)',
|
borderColor: 'rgba(119, 47, 0, 0.05)',
|
||||||
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
favThumbText: {
|
favThumbText: {
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
|
|||||||
@@ -1,54 +1,28 @@
|
|||||||
import WidgetKit
|
import WidgetKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// V2:纯色背景 + 随机文案小组件(Small/Medium/Large + 点击跳转 Home)
|
// V1:写死文案的小组件(Small/Medium/Large + 点击跳转 Home)
|
||||||
|
|
||||||
struct EmotionProvider: TimelineProvider {
|
struct EmotionProvider: TimelineProvider {
|
||||||
private let quotes = [
|
|
||||||
"你已经很努力了,今天也值得被温柔对待。",
|
|
||||||
"轻轻呼吸,感受当下的每一刻。",
|
|
||||||
"所有的压力,都会在深呼吸中慢慢消散。",
|
|
||||||
"给生活一点留白,给自己一点温柔。",
|
|
||||||
"不要走得太快,等一等落下的灵魂。",
|
|
||||||
"世界虽嘈杂,但你可以拥有一颗宁静的心。",
|
|
||||||
"每一个瞬间,都是生命最好的安排。",
|
|
||||||
"抱抱自己,辛苦了,亲爱的。",
|
|
||||||
"慢一点也没关系,只要你在前行。",
|
|
||||||
"今天,你对自己微笑了吗?",
|
|
||||||
"愿你历经山河,仍觉得人间值得。",
|
|
||||||
"心简单,世界就简单;心平顺,生活就平顺。",
|
|
||||||
"即使生活偶尔晦暗,你也要成为自己的光。",
|
|
||||||
"别让琐事挤走生活的快乐,别让压力消磨奋斗的激情。"
|
|
||||||
]
|
|
||||||
|
|
||||||
func placeholder(in context: Context) -> EmotionEntry {
|
func placeholder(in context: Context) -> EmotionEntry {
|
||||||
EmotionEntry(date: Date(), text: quotes[0])
|
EmotionEntry(date: Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
func getSnapshot(in context: Context, completion: @escaping (EmotionEntry) -> ()) {
|
func getSnapshot(in context: Context, completion: @escaping (EmotionEntry) -> ()) {
|
||||||
let entry = EmotionEntry(date: Date(), text: quotes.randomElement() ?? quotes[0])
|
completion(EmotionEntry(date: Date()))
|
||||||
completion(entry)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTimeline(in context: Context, completion: @escaping (Timeline<EmotionEntry>) -> ()) {
|
func getTimeline(in context: Context, completion: @escaping (Timeline<EmotionEntry>) -> ()) {
|
||||||
var entries: [EmotionEntry] = []
|
// V1:内容写死,不做数据更新;给一个较长的刷新间隔(系统仍可能自行调度)
|
||||||
let currentDate = Date()
|
let entry = EmotionEntry(date: Date())
|
||||||
|
let nextUpdate = Calendar.current.date(byAdding: .day, value: 7, to: Date())
|
||||||
// 生成未来 24 小时的 6 个条目,每 4 小时更换一次随机文案
|
?? Date().addingTimeInterval(60 * 60 * 24 * 7)
|
||||||
for hourOffset in 0..<6 {
|
completion(Timeline(entries: [entry], policy: .after(nextUpdate)))
|
||||||
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset * 4, to: currentDate)!
|
|
||||||
let entry = EmotionEntry(date: entryDate, text: quotes.randomElement() ?? quotes[0])
|
|
||||||
entries.append(entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
let timeline = Timeline(entries: entries, policy: .atEnd)
|
|
||||||
completion(timeline)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct EmotionEntry: TimelineEntry {
|
struct EmotionEntry: TimelineEntry {
|
||||||
let date: Date
|
let date: Date
|
||||||
let text: String
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct EmotionWidgetView: View {
|
struct EmotionWidgetView: View {
|
||||||
@@ -56,37 +30,160 @@ struct EmotionWidgetView: View {
|
|||||||
@Environment(\.widgetFamily) var family
|
@Environment(\.widgetFamily) var family
|
||||||
|
|
||||||
private let title = "正念"
|
private let title = "正念"
|
||||||
|
private let text = "你已经很努力了,今天也值得被温柔对待。"
|
||||||
private let deepLink = URL(string: "client:///(app)/home")
|
private let deepLink = URL(string: "client:///(app)/home")
|
||||||
|
|
||||||
// 背景色 #F7D9BF
|
|
||||||
private let backgroundColor = Color(red: 247/255, green: 217/255, blue: 191/255)
|
|
||||||
// 文本颜色(深咖色,适合搭配浅橘色背景)
|
|
||||||
private let textColor = Color(red: 74/255, green: 52/255, blue: 40/255)
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .center, spacing: 0) {
|
switch family {
|
||||||
Spacer(minLength: 0)
|
case .systemSmall:
|
||||||
|
smallView()
|
||||||
Text(entry.text)
|
case .systemMedium:
|
||||||
.font(.system(size: family == .systemSmall ? 17 : 20, weight: .medium))
|
mediumView()
|
||||||
.foregroundColor(textColor)
|
case .systemLarge:
|
||||||
.lineSpacing(6)
|
largeView()
|
||||||
.multilineTextAlignment(.center)
|
default:
|
||||||
.minimumScaleFactor(0.7)
|
smallView()
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
}
|
||||||
|
}
|
||||||
Spacer(minLength: 0)
|
|
||||||
|
// 统一的“卡片背景”风格(iOS 15 兼容)
|
||||||
if family != .systemSmall {
|
private func cardBackground(colors: [Color]) -> some View {
|
||||||
Text("Hey Mama")
|
ZStack {
|
||||||
.font(.system(size: 10, weight: .semibold))
|
LinearGradient(
|
||||||
.foregroundColor(textColor.opacity(0.3))
|
colors: colors,
|
||||||
.padding(.bottom, 4)
|
startPoint: .topLeading,
|
||||||
}
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
// 轻微光斑,增加层次
|
||||||
|
RadialGradient(
|
||||||
|
gradient: Gradient(colors: [Color.white.opacity(0.16), Color.white.opacity(0.0)]),
|
||||||
|
center: .topTrailing,
|
||||||
|
startRadius: 10,
|
||||||
|
endRadius: 180
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||||
|
.stroke(Color.white.opacity(0.14), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.cornerRadius(18)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func chip(_ text: String) -> some View {
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundColor(Color.white.opacity(0.9))
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Color.white.opacity(0.14))
|
||||||
|
.cornerRadius(999)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func smallView() -> some View {
|
||||||
|
ZStack {
|
||||||
|
cardBackground(colors: [
|
||||||
|
Color(red: 0.06, green: 0.08, blue: 0.12),
|
||||||
|
Color(red: 0.13, green: 0.16, blue: 0.22),
|
||||||
|
])
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack {
|
||||||
|
chip(title)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundColor(Color.white.opacity(0.92))
|
||||||
|
.lineSpacing(2)
|
||||||
|
.lineLimit(4)
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
Text("点我回到 App")
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundColor(Color.white.opacity(0.65))
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
}
|
||||||
|
.widgetURL(deepLink)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func mediumView() -> some View {
|
||||||
|
ZStack {
|
||||||
|
cardBackground(colors: [
|
||||||
|
Color(red: 0.06, green: 0.08, blue: 0.12),
|
||||||
|
Color(red: 0.09, green: 0.11, blue: 0.17),
|
||||||
|
])
|
||||||
|
|
||||||
|
HStack(alignment: .top, spacing: 14) {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
chip(title)
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: 17, weight: .semibold))
|
||||||
|
.foregroundColor(Color.white.opacity(0.92))
|
||||||
|
.lineSpacing(3)
|
||||||
|
.lineLimit(5)
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
Text("轻轻呼吸,回到当下")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundColor(Color.white.opacity(0.7))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右侧装饰区:让版面更饱满
|
||||||
|
VStack(alignment: .trailing, spacing: 8) {
|
||||||
|
Text(entry.date, style: .time)
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundColor(Color.white.opacity(0.8))
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Text("今日")
|
||||||
|
.font(.system(size: 28, weight: .bold))
|
||||||
|
.foregroundColor(Color.white.opacity(0.12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
.widgetURL(deepLink)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func largeView() -> some View {
|
||||||
|
ZStack {
|
||||||
|
cardBackground(colors: [
|
||||||
|
Color(red: 0.06, green: 0.08, blue: 0.12),
|
||||||
|
Color(red: 0.14, green: 0.18, blue: 0.28),
|
||||||
|
])
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
HStack {
|
||||||
|
chip(title)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Text(entry.date, style: .time)
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundColor(Color.white.opacity(0.78))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: 20, weight: .semibold))
|
||||||
|
.foregroundColor(Color.white.opacity(0.92))
|
||||||
|
.lineSpacing(4)
|
||||||
|
.lineLimit(8)
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("点我回到 Home")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundColor(Color.white.opacity(0.7))
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Text("🌿")
|
||||||
|
.font(.system(size: 18))
|
||||||
|
.opacity(0.9)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(18)
|
||||||
}
|
}
|
||||||
.padding(family == .systemSmall ? 16 : 24)
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity) // 强制撑开容器
|
|
||||||
.background(backgroundColor) // 将背景色直接应用到容器上
|
|
||||||
.widgetURL(deepLink)
|
.widgetURL(deepLink)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,13 +194,7 @@ struct EmotionWidget: Widget {
|
|||||||
|
|
||||||
var body: some WidgetConfiguration {
|
var body: some WidgetConfiguration {
|
||||||
StaticConfiguration(kind: kind, provider: EmotionProvider()) { entry in
|
StaticConfiguration(kind: kind, provider: EmotionProvider()) { entry in
|
||||||
if #available(iOS 17.0, *) {
|
EmotionWidgetView(entry: entry)
|
||||||
EmotionWidgetView(entry: entry)
|
|
||||||
.containerBackground(Color(red: 247/255, green: 217/255, blue: 191/255), for: .widget)
|
|
||||||
} else {
|
|
||||||
EmotionWidgetView(entry: entry)
|
|
||||||
.background(Color(red: 247/255, green: 217/255, blue: 191/255))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.configurationDisplayName("情绪小组件")
|
.configurationDisplayName("情绪小组件")
|
||||||
.description("一段温柔提醒,陪你回到当下。")
|
.description("一段温柔提醒,陪你回到当下。")
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export async function setReaction(contentId: string, reaction: Reaction): Promis
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type FavoriteItem = {
|
export type FavoriteItem = {
|
||||||
|
favId: string; // 唯一标识,支持重复点赞同一文案
|
||||||
id: string;
|
id: string;
|
||||||
date: string;
|
date: string;
|
||||||
themeMode: ThemeMode;
|
themeMode: ThemeMode;
|
||||||
@@ -117,15 +118,15 @@ export async function getFavorites(): Promise<FavoriteItem[]> {
|
|||||||
|
|
||||||
export async function addFavorite(item: FavoriteItem): Promise<void> {
|
export async function addFavorite(item: FavoriteItem): Promise<void> {
|
||||||
const list = await getFavorites();
|
const list = await getFavorites();
|
||||||
if (list.some(i => i.id === item.id)) return;
|
// 允许重复点赞,不再根据 id 去重
|
||||||
const newList = [item, ...list];
|
const newList = [item, ...list];
|
||||||
console.log('Adding to favorites, new list size:', newList.length);
|
console.log('Adding to favorites, new list size:', newList.length);
|
||||||
await setJson(KEY_FAVORITES_ITEMS, newList);
|
await setJson(KEY_FAVORITES_ITEMS, newList);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeFavorite(contentId: string): Promise<void> {
|
export async function removeFavorite(favId: string): Promise<void> {
|
||||||
const list = await getFavorites();
|
const list = await getFavorites();
|
||||||
const next = list.filter(item => item.id !== contentId);
|
const next = list.filter(item => item.favId !== favId);
|
||||||
await setJson(KEY_FAVORITES_ITEMS, next);
|
await setJson(KEY_FAVORITES_ITEMS, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||