无图片默认渐变色头像

This commit is contained in:
xianyi
2026-01-22 10:52:17 +08:00
parent cf9604d73e
commit 9e7f8dadec
4 changed files with 242 additions and 225 deletions

View File

@@ -5,6 +5,7 @@ import { Colors } from "@/constants/theme";
import { useAppState } from "@/context/AppStateContext";
import { useTheme } from "@/context/ThemeContext";
import { fetchSearch } from "@/lib/api";
import { getInitials, getLogoGradient } from "@/lib/avatar-utils";
import { SearchLeague, SearchPlayer, SearchTeam } from "@/types/api";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { BlurView } from "expo-blur";
@@ -78,65 +79,6 @@ function convertTeamToEntity(team: SearchTeam): SearchEntity {
};
}
// 生成首字母
function getInitials(name: string): string {
const s = name.trim();
if (!s) return "SN";
const clean = s.replace(/[^a-zA-Z0-9]+/g, " ").trim();
const parts = clean ? clean.split(/\s+/) : [s];
const a = (parts[0] || s).charAt(0) || "S";
const b = (parts[1] || "").charAt(0) || ((parts[0] || s).charAt(1) || "N");
return (a + b).toUpperCase();
}
// 生成颜色哈希
function hashString(str: string): number {
let hash = 2166136261;
for (let i = 0; i < str.length; i++) {
hash ^= str.charCodeAt(i);
hash = (hash * 16777619) >>> 0;
}
return hash >>> 0;
}
// HSL 转 RGB
function hslToRgb(h: number, s: number, l: number): string {
s /= 100;
l /= 100;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = l - c / 2;
let r = 0, g = 0, b = 0;
if (0 <= h && h < 60) {
r = c; g = x; b = 0;
} else if (60 <= h && h < 120) {
r = x; g = c; b = 0;
} else if (120 <= h && h < 180) {
r = 0; g = c; b = x;
} else if (180 <= h && h < 240) {
r = 0; g = x; b = c;
} else if (240 <= h && h < 300) {
r = x; g = 0; b = c;
} else if (300 <= h && h < 360) {
r = c; g = 0; b = x;
}
r = Math.round((r + m) * 255);
g = Math.round((g + m) * 255);
b = Math.round((b + m) * 255);
return `rgb(${r}, ${g}, ${b})`;
}
// 生成渐变颜色
function getLogoGradient(name: string): { color1: string; color2: string } {
const h = hashString(name);
const hue = h % 360;
const hue2 = (hue + 36 + (h % 24)) % 360;
return {
color1: hslToRgb(hue, 85, 58),
color2: hslToRgb(hue2, 85, 48),
};
}
// 获取副标题文本
function getSubText(entity: SearchEntity): string {
@@ -171,6 +113,8 @@ export default function SearchScreen() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const isDark = theme === "dark";
const cardBg = isDark ? "#1C1C1E" : "#FFFFFF";
const borderColor = isDark ? "#2C2C2E" : "rgba(0,0,0,0.06)";
const { state } = useAppState();
const [searchType, setSearchType] = useState<SearchType>("all");
@@ -181,6 +125,7 @@ export default function SearchScreen() {
const [entities, setEntities] = useState<SearchEntity[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [hasSearched, setHasSearched] = useState(false);
const sheetAnim = useRef(new Animated.Value(0)).current;
const searchTimeoutRef = useRef<number | null>(null);
@@ -226,6 +171,7 @@ export default function SearchScreen() {
if (!query.trim()) {
setEntities([]);
setError(null);
setHasSearched(false);
return;
}
@@ -245,6 +191,7 @@ export default function SearchScreen() {
];
setEntities(allEntities);
setHasSearched(true);
if (allEntities.length > 0) {
const searchQuery = query.trim();
setRecentSearches((prev) => {
@@ -260,6 +207,7 @@ export default function SearchScreen() {
console.error("Search error:", err);
setError(err?.message || t("search.error"));
setEntities([]);
setHasSearched(true);
} finally {
setLoading(false);
}
@@ -328,12 +276,8 @@ export default function SearchScreen() {
style={[
styles.search,
{
backgroundColor: isDark
? "rgba(0, 0, 0, 0.28)"
: "rgba(255, 255, 255, 0.8)",
borderColor: isDark
? "rgba(255, 255, 255, 0.12)"
: "rgba(0, 0, 0, 0.1)",
backgroundColor: cardBg,
borderColor: borderColor,
},
]}
>
@@ -368,20 +312,8 @@ export default function SearchScreen() {
styles.chip,
searchType === type && styles.chipActive,
{
backgroundColor: isDark
? searchType === type
? "rgba(255, 255, 255, 0.12)"
: "rgba(255, 255, 255, 0.06)"
: searchType === type
? "rgba(0, 0, 0, 0.08)"
: "rgba(0, 0, 0, 0.04)",
borderColor: isDark
? searchType === type
? "rgba(255, 255, 255, 0.14)"
: "rgba(255, 255, 255, 0.10)"
: searchType === type
? "rgba(0, 0, 0, 0.12)"
: "rgba(0, 0, 0, 0.08)",
backgroundColor: cardBg,
borderColor: borderColor,
},
]}
onPress={() => handleSearchTypeChange(type)}
@@ -426,12 +358,8 @@ export default function SearchScreen() {
style={[
styles.recentCard,
{
backgroundColor: isDark
? "rgba(255, 255, 255, 0.05)"
: "rgba(0, 0, 0, 0.04)",
borderColor: isDark
? "rgba(255, 255, 255, 0.10)"
: "rgba(0, 0, 0, 0.08)",
backgroundColor: cardBg,
borderColor: borderColor,
},
]}
onPress={() => handleRecentClick(item)}
@@ -458,12 +386,8 @@ export default function SearchScreen() {
style={[
styles.entityRow,
{
backgroundColor: isDark
? "rgba(255, 255, 255, 0.06)"
: "rgba(0, 0, 0, 0.04)",
borderColor: isDark
? "rgba(255, 255, 255, 0.10)"
: "rgba(0, 0, 0, 0.08)",
backgroundColor: cardBg,
borderColor: borderColor,
},
]}
onPress={() => handleEntityPress(item)}
@@ -503,12 +427,8 @@ export default function SearchScreen() {
style={[
styles.emptyState,
{
backgroundColor: isDark
? "rgba(255, 255, 255, 0.05)"
: "rgba(0, 0, 0, 0.04)",
borderColor: isDark
? "rgba(255, 255, 255, 0.10)"
: "rgba(0, 0, 0, 0.08)",
backgroundColor: cardBg,
borderColor: borderColor,
},
]}
>
@@ -547,12 +467,8 @@ export default function SearchScreen() {
styles.sheet,
{
transform: [{ translateY }],
backgroundColor: isDark
? "rgba(18, 20, 26, 0.96)"
: "rgba(255, 255, 255, 0.96)",
borderColor: isDark
? "rgba(255, 255, 255, 0.12)"
: "rgba(0, 0, 0, 0.1)",
backgroundColor: cardBg,
borderColor: borderColor,
},
{ paddingBottom: insets.bottom + 14 },
]}
@@ -571,12 +487,8 @@ export default function SearchScreen() {
style={[
styles.iconBtn,
{
backgroundColor: isDark
? "rgba(255, 255, 255, 0.06)"
: "rgba(0, 0, 0, 0.06)",
borderColor: isDark
? "rgba(255, 255, 255, 0.10)"
: "rgba(0, 0, 0, 0.10)",
backgroundColor: cardBg,
borderColor: borderColor,
},
]}
onPress={() => setShowDetailSheet(false)}
@@ -597,12 +509,8 @@ export default function SearchScreen() {
style={[
styles.kvItem,
{
backgroundColor: isDark
? "rgba(255, 255, 255, 0.05)"
: "rgba(0, 0, 0, 0.04)",
borderColor: isDark
? "rgba(255, 255, 255, 0.10)"
: "rgba(0, 0, 0, 0.08)",
backgroundColor: cardBg,
borderColor: borderColor,
},
]}
>
@@ -619,12 +527,8 @@ export default function SearchScreen() {
style={[
styles.kvItem,
{
backgroundColor: isDark
? "rgba(255, 255, 255, 0.05)"
: "rgba(0, 0, 0, 0.04)",
borderColor: isDark
? "rgba(255, 255, 255, 0.10)"
: "rgba(0, 0, 0, 0.08)",
backgroundColor: cardBg,
borderColor: borderColor,
},
]}
>
@@ -645,12 +549,8 @@ export default function SearchScreen() {
style={[
styles.kvItem,
{
backgroundColor: isDark
? "rgba(255, 255, 255, 0.05)"
: "rgba(0, 0, 0, 0.04)",
borderColor: isDark
? "rgba(255, 255, 255, 0.10)"
: "rgba(0, 0, 0, 0.08)",
backgroundColor: cardBg,
borderColor: borderColor,
},
]}
>
@@ -667,12 +567,8 @@ export default function SearchScreen() {
style={[
styles.kvItem,
{
backgroundColor: isDark
? "rgba(255, 255, 255, 0.05)"
: "rgba(0, 0, 0, 0.04)",
borderColor: isDark
? "rgba(255, 255, 255, 0.10)"
: "rgba(0, 0, 0, 0.08)",
backgroundColor: cardBg,
borderColor: borderColor,
},
]}
>
@@ -689,12 +585,8 @@ export default function SearchScreen() {
style={[
styles.kvItem,
{
backgroundColor: isDark
? "rgba(255, 255, 255, 0.05)"
: "rgba(0, 0, 0, 0.04)",
borderColor: isDark
? "rgba(255, 255, 255, 0.10)"
: "rgba(0, 0, 0, 0.08)",
backgroundColor: cardBg,
borderColor: borderColor,
},
]}
>
@@ -711,12 +603,8 @@ export default function SearchScreen() {
style={[
styles.kvItem,
{
backgroundColor: isDark
? "rgba(255, 255, 255, 0.05)"
: "rgba(0, 0, 0, 0.04)",
borderColor: isDark
? "rgba(255, 255, 255, 0.10)"
: "rgba(0, 0, 0, 0.08)",
backgroundColor: cardBg,
borderColor: borderColor,
},
]}
>
@@ -737,12 +625,8 @@ export default function SearchScreen() {
style={[
styles.kvItem,
{
backgroundColor: isDark
? "rgba(255, 255, 255, 0.05)"
: "rgba(0, 0, 0, 0.04)",
borderColor: isDark
? "rgba(255, 255, 255, 0.10)"
: "rgba(0, 0, 0, 0.08)",
backgroundColor: cardBg,
borderColor: borderColor,
},
]}
>
@@ -763,12 +647,8 @@ export default function SearchScreen() {
style={[
styles.sheetBtn,
{
backgroundColor: isDark
? "rgba(0, 0, 0, 0.14)"
: "rgba(0, 0, 0, 0.06)",
borderColor: isDark
? "rgba(255, 255, 255, 0.12)"
: "rgba(0, 0, 0, 0.1)",
backgroundColor: cardBg,
borderColor: borderColor,
},
]}
onPress={handleOpenDetail}
@@ -822,9 +702,7 @@ export default function SearchScreen() {
style={[
styles.nav,
{
backgroundColor: isDark
? "rgba(7, 8, 11, 0.95)"
: "rgba(255, 255, 255, 0.95)",
backgroundColor: cardBg,
},
{ paddingTop: insets.top + 10 },
]}
@@ -833,12 +711,8 @@ export default function SearchScreen() {
style={[
styles.iconBtn,
{
backgroundColor: isDark
? "rgba(255, 255, 255, 0.06)"
: "rgba(0, 0, 0, 0.06)",
borderColor: isDark
? "rgba(255, 255, 255, 0.10)"
: "rgba(0, 0, 0, 0.10)",
backgroundColor: cardBg,
borderColor: borderColor,
},
]}
onPress={() => router.back()}
@@ -873,8 +747,8 @@ export default function SearchScreen() {
{loading
? t("search.loading")
: error
? t("search.error")
: `${filteredEntities.length} ${t("search.found")}`}
? t("search.error")
: `${filteredEntities.length} ${t("search.found")}`}
</ThemedText>
</View>
)}
@@ -892,12 +766,13 @@ export default function SearchScreen() {
{t("search.error_hint")}
</ThemedText>
</View>
) : hasSearched && filteredEntities.length === 0 ? (
renderEmptyState()
) : (
<FlatList
data={filteredEntities}
keyExtractor={(item) => item.id}
renderItem={renderEntityItem}
ListEmptyComponent={() => renderEmptyState()}
contentContainerStyle={[
styles.listContent,
{ paddingBottom: insets.bottom + 20 },
@@ -942,13 +817,13 @@ const styles = StyleSheet.create({
},
brandTitle: {
fontSize: 18,
fontWeight: "900",
fontWeight: "700",
letterSpacing: 0.2,
},
brandSub: {
fontSize: 12,
opacity: 0.42,
fontWeight: "800",
opacity: 0.5,
fontWeight: "600",
},
content: {
flex: 1,
@@ -993,9 +868,9 @@ const styles = StyleSheet.create({
},
chipActive: {},
chipText: {
fontSize: 14,
fontWeight: "900",
opacity: 0.78,
fontSize: 12,
fontWeight: "700",
opacity: 0.8,
},
chipTextActive: {
opacity: 1,
@@ -1008,14 +883,14 @@ const styles = StyleSheet.create({
marginBottom: 8,
},
sectionTitle: {
fontSize: 13,
opacity: 0.55,
fontWeight: "900",
fontSize: 12,
opacity: 0.8,
fontWeight: "700",
},
sectionAction: {
fontSize: 12,
opacity: 0.42,
fontWeight: "800",
opacity: 0.5,
fontWeight: "600",
},
recentRow: {
marginBottom: 8,
@@ -1031,12 +906,14 @@ const styles = StyleSheet.create({
borderWidth: 1,
},
recentCardTitle: {
fontWeight: "900",
fontSize: 12,
fontWeight: "600",
},
recentCardSub: {
fontSize: 12,
opacity: 0.55,
opacity: 0.5,
marginTop: 4,
fontWeight: "500",
},
entityRow: {
flexDirection: "row",
@@ -1057,7 +934,7 @@ const styles = StyleSheet.create({
},
logoText: {
fontSize: 12,
fontWeight: "900",
fontWeight: "700",
color: "rgba(255, 255, 255, 0.92)",
},
entityMeta: {
@@ -1065,17 +942,19 @@ const styles = StyleSheet.create({
minWidth: 0,
},
entityName: {
fontWeight: "900",
fontSize: 12,
fontWeight: "600",
},
entitySub: {
fontSize: 12,
opacity: 0.55,
opacity: 0.5,
marginTop: 3,
fontWeight: "500",
},
entityBadge: {
fontSize: 12,
opacity: 0.52,
fontWeight: "900",
fontSize: 10,
opacity: 0.9,
fontWeight: "700",
},
center: {
flex: 1,
@@ -1089,13 +968,14 @@ const styles = StyleSheet.create({
borderWidth: 1,
},
emptyTitle: {
fontWeight: "900",
fontSize: 12,
fontWeight: "600",
},
emptySub: {
marginTop: 6,
opacity: 0.6,
opacity: 0.5,
fontSize: 12,
fontWeight: "800",
fontWeight: "500",
lineHeight: 18,
},
overlay: {
@@ -1130,12 +1010,13 @@ const styles = StyleSheet.create({
minWidth: 0,
},
sheetTitle: {
fontWeight: "900",
fontSize: 12,
fontWeight: "600",
},
sheetSub: {
fontSize: 12,
opacity: 0.52,
fontWeight: "800",
opacity: 0.5,
fontWeight: "500",
marginTop: 2,
},
kvContainer: {
@@ -1153,12 +1034,13 @@ const styles = StyleSheet.create({
},
kvLabel: {
fontSize: 12,
opacity: 0.52,
fontWeight: "900",
opacity: 0.5,
fontWeight: "700",
},
kvValue: {
marginTop: 6,
fontWeight: "900",
fontSize: 12,
fontWeight: "600",
},
sheetActions: {
flexDirection: "row",
@@ -1175,9 +1057,9 @@ const styles = StyleSheet.create({
},
sheetBtnPrimary: {},
sheetBtnText: {
fontSize: 14,
fontWeight: "900",
opacity: 0.86,
fontSize: 12,
fontWeight: "700",
opacity: 0.9,
},
sheetBtnTextPrimary: {
opacity: 1,

View File

@@ -4,7 +4,9 @@ import { Colors } from "@/constants/theme";
import { useAppState } from "@/context/AppStateContext";
import { useTheme } from "@/context/ThemeContext";
import { addFavorite, removeFavorite } from "@/lib/api";
import { getInitials, getLogoGradient } from "@/lib/avatar-utils";
import { Match } from "@/types/api";
import { LinearGradient } from "expo-linear-gradient";
import { useRouter } from "expo-router";
import React, { useState } from "react";
import { Image, Pressable, StyleSheet, TouchableOpacity, View } from "react-native";
@@ -179,14 +181,31 @@ export function MatchCardLeague({
<View style={styles.teamsColumn}>
<View style={styles.teamRow}>
<Image
source={{
uri: isTennis
? (match as any).eventFirstPlayerLogo
: ((match as any).homeLogo || match.homeTeamLogo || "https://placehold.co/24x24/png")
}}
style={styles.teamLogo}
/>
{(() => {
const teamName = isTennis ? (match as any).eventFirstPlayer : (match.home || match.homeTeamName);
const logoUri = isTennis
? (match as any).eventFirstPlayerLogo
: ((match as any).homeLogo || match.homeTeamLogo);
const hasLogo = logoUri && logoUri.trim() !== "" && !logoUri.includes("placehold");
const gradient = getLogoGradient(teamName || "");
const initials = getInitials(teamName || "");
return hasLogo ? (
<Image
source={{ uri: logoUri }}
style={styles.teamLogo}
/>
) : (
<LinearGradient
colors={[gradient.color1, gradient.color2]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.teamLogoGradient}
>
<ThemedText style={styles.teamLogoText}>{initials}</ThemedText>
</LinearGradient>
);
})()}
<View style={styles.teamNameContainer}>
<ThemedText style={[styles.teamName, { color: textColor }]} numberOfLines={1}>
{isTennis ? (match as any).eventFirstPlayer : (match.home || match.homeTeamName)}
@@ -197,14 +216,31 @@ export function MatchCardLeague({
</View>
<View style={[styles.teamRow, { marginTop: 10 }]}>
<Image
source={{
uri: isTennis
? (match as any).eventSecondPlayerLogo
: ((match as any).awayLogo || match.awayTeamLogo || "https://placehold.co/24x24/png")
}}
style={styles.teamLogo}
/>
{(() => {
const teamName = isTennis ? (match as any).eventSecondPlayer : (match.away || match.awayTeamName);
const logoUri = isTennis
? (match as any).eventSecondPlayerLogo
: ((match as any).awayLogo || match.awayTeamLogo);
const hasLogo = logoUri && logoUri.trim() !== "" && !logoUri.includes("placehold");
const gradient = getLogoGradient(teamName || "");
const initials = getInitials(teamName || "");
return hasLogo ? (
<Image
source={{ uri: logoUri }}
style={styles.teamLogo}
/>
) : (
<LinearGradient
colors={[gradient.color1, gradient.color2]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.teamLogoGradient}
>
<ThemedText style={styles.teamLogoText}>{initials}</ThemedText>
</LinearGradient>
);
})()}
<View style={styles.teamNameContainer}>
<ThemedText style={[styles.teamName, { color: textColor }]} numberOfLines={1}>
{isTennis ? (match as any).eventSecondPlayer : (match.away || match.awayTeamName)}
@@ -310,13 +346,26 @@ const styles = StyleSheet.create({
marginRight: 12,
backgroundColor: "#E5E5E5",
},
teamLogoGradient: {
width: 24,
height: 24,
borderRadius: 14,
marginRight: 12,
alignItems: "center",
justifyContent: "center",
},
teamLogoText: {
fontSize: 9,
fontWeight: "700",
color: "rgba(255, 255, 255, 0.92)",
},
teamNameContainer: {
flexDirection: "row",
alignItems: "center",
flex: 1,
},
teamName: {
fontSize: 15,
fontSize: 13,
fontWeight: "500",
},
cardBadge: {

View File

@@ -3,8 +3,10 @@ import { ThemedText } from "@/components/themed-text";
import { Colors } from "@/constants/theme";
import { useTheme } from "@/context/ThemeContext";
import { fetchLeagues, fetchTodayMatches } from "@/lib/api";
import { getInitials, getLogoGradient } from "@/lib/avatar-utils";
import { League, Match } from "@/types/api";
import { Image } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
import React, { useState } from "react";
import {
ActivityIndicator,
@@ -208,12 +210,27 @@ export function MatchesByLeague({
style={[styles.leagueHeaderWrapper, { backgroundColor: headerBg }]}
>
<View style={styles.leagueHeaderLeft}>
<Image
source={{
uri: league.logo || "https://placehold.co/40x40/png",
}}
style={[styles.leagueLogo, { backgroundColor: isDark ? "#3A3A3C" : "#E5E5E5" }]}
/>
{(() => {
const hasLogo = league.logo && league.logo.trim() !== "" && !league.logo.includes("placehold");
const gradient = getLogoGradient(league.name || "");
const initials = getInitials(league.name || "");
return hasLogo ? (
<Image
source={{ uri: league.logo }}
style={[styles.leagueLogo, { backgroundColor: isDark ? "#3A3A3C" : "#E5E5E5" }]}
/>
) : (
<LinearGradient
colors={[gradient.color1, gradient.color2]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.leagueLogoGradient}
>
<ThemedText style={styles.leagueLogoText}>{initials}</ThemedText>
</LinearGradient>
);
})()}
<View style={styles.leagueInfoText}>
<ThemedText
@@ -326,6 +343,20 @@ const styles = StyleSheet.create({
marginRight: 12,
marginLeft: 14,
},
leagueLogoGradient: {
width: 24,
height: 24,
borderRadius: 8,
marginRight: 12,
marginLeft: 14,
alignItems: "center",
justifyContent: "center",
},
leagueLogoText: {
fontSize: 9,
fontWeight: "700",
color: "rgba(255, 255, 255, 0.92)",
},
leagueInfoText: {
justifyContent: "center",
},

55
lib/avatar-utils.ts Normal file
View File

@@ -0,0 +1,55 @@
export function getInitials(name: string): string {
const s = name.trim();
if (!s) return "SN";
const clean = s.replace(/[^a-zA-Z0-9]+/g, " ").trim();
const parts = clean ? clean.split(/\s+/) : [s];
const a = (parts[0] || s).charAt(0) || "S";
const b = (parts[1] || "").charAt(0) || ((parts[0] || s).charAt(1) || "N");
return (a + b).toUpperCase();
}
function hashString(str: string): number {
let hash = 2166136261;
for (let i = 0; i < str.length; i++) {
hash ^= str.charCodeAt(i);
hash = (hash * 16777619) >>> 0;
}
return hash >>> 0;
}
function hslToRgb(h: number, s: number, l: number): string {
s /= 100;
l /= 100;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = l - c / 2;
let r = 0, g = 0, b = 0;
if (0 <= h && h < 60) {
r = c; g = x; b = 0;
} else if (60 <= h && h < 120) {
r = x; g = c; b = 0;
} else if (120 <= h && h < 180) {
r = 0; g = c; b = x;
} else if (180 <= h && h < 240) {
r = 0; g = x; b = c;
} else if (240 <= h && h < 300) {
r = x; g = 0; b = c;
} else if (300 <= h && h < 360) {
r = c; g = 0; b = x;
}
r = Math.round((r + m) * 255);
g = Math.round((g + m) * 255);
b = Math.round((b + m) * 255);
return `rgb(${r}, ${g}, ${b})`;
}
export function getLogoGradient(name: string): { color1: string; color2: string } {
const h = hashString(name);
const hue = h % 360;
const hue2 = (hue + 36 + (h % 24)) % 360;
return {
color1: hslToRgb(hue, 85, 58),
color2: hslToRgb(hue2, 85, 48),
};
}