goon/mobile/src/ui.tsx
goon-foss 7ed878ce16 feat(mobile): gorne belki w jednym wierszu, pasek w Edge chowa sie przy przewijaniu
- SegmentTabs w ui.tsx: zakladki w wierszu narzedzi obok ikon
- Ulubione, performerka, studio, Performers i Tags: jeden wiersz pod naglowkiem
  zamiast trzech-czterech (etykieta z licznikiem, zakladki, podpowiedz, sortowanie)
- gwiazdka na ekranie performerki i studia bierze /favorites?counts=false
- Edge: kolejny klip chowa pasek, cofniecie pokazuje; podpis, przyciski i postep
  jada razem z paskiem; FAB zgloszen ukryty, lezal na "Not interested"
- changelog 2026-09-15c/d

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 15:37:53 +02:00

326 lines
9.8 KiB
TypeScript

import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RootStackParamList } from './navigation';
/**
* Wspólny zestaw kontrolek nagłówka ekranów-list (Scenes, Movies, Sites, JAV, Anime).
*
* Po co: każdy ekran miał WŁASNĄ kopię stylów `search`/`toolbar`/przycisku filtra,
* definiowaną niezależnie, więc rozjechały się na wszystkim naraz (zgłoszenie 2026-08-18,
* cztery zrzuty obok siebie): róg pola 12 vs 10, ramka 1,5 vs 1, font 16 vs 14, odstępy
* 12 vs 8, dwa promienie pigułek (8 i 12), a etykiety miały `fontWeight:'700'` BEZ
* `fontFamily` — RN nie syntezuje pogrubienia dla fontu custom, więc renderowały się
* chudym Regularem (stąd wrażenie „innych czcionek"). Jeden moduł = jeden wygląd.
*
* Zasady: jedna wysokość kontrolek w wierszu (pole = przycisk filtra), jeden promień,
* jedna ramka, waga przez `fontFamily` (nigdy przez `fontWeight` na custom foncie),
* stan aktywny robi kolor (accentText + delikatny tint), nie kształt.
*/
import React from 'react';
import {
Pressable,
StyleProp,
StyleSheet,
Text,
TextInput,
TextInputProps,
View,
ViewStyle,
} from 'react-native';
import { fonts, theme } from './theme';
/** Poziomy padding treści list i wysokość kontrolek w wierszu narzędzi. Wspólne, żeby
* pola i przyciski na każdej zakładce zaczynały się i kończyły w tej samej linii. */
export const SCREEN_PAD_H = 16;
export const SCREEN_PAD_TOP = 14;
export const CONTROL_H = 44;
const RADIUS = 12;
/** Pole wyszukiwania. Ramka focus zarządzana wewnątrz, żeby każdy ekran nie powtarzał
* tego samego `searchFocused`. Wypełnia szerokość (flex:1) w wierszu z przyciskiem. */
export function SearchInput(props: TextInputProps) {
const [focused, setFocused] = React.useState(false);
return (
<TextInput
placeholderTextColor={theme.muted}
autoCapitalize="none"
{...props}
onFocus={(e) => {
setFocused(true);
props.onFocus?.(e);
}}
onBlur={(e) => {
setFocused(false);
props.onBlur?.(e);
}}
style={[styles.search, focused && styles.searchFocused, props.style]}
/>
);
}
/** Pigułka-przycisk (Filter, Performers, Tags, ★, Top/Recent). `compact` = niższy wariant
* dla wiersza akcji pod polem. `active` przełącza kolor, nie kształt. */
export function PillButton({
label,
active,
onPress,
onLongPress,
compact,
tone = 'accent',
style,
}: {
label: string;
active?: boolean;
onPress?: () => void;
onLongPress?: () => void;
compact?: boolean;
/** 'accent' = czerwień (filtr/aktywny), 'secondary' = amber (Performers/Tags). */
tone?: 'accent' | 'secondary';
style?: StyleProp<ViewStyle>;
}) {
const textColor = active ? theme.accentText : tone === 'secondary' ? theme.accentSecondary : theme.fg;
return (
<Pressable
onPress={onPress}
onLongPress={onLongPress}
style={[
styles.pill,
compact ? styles.pillCompact : styles.pillTall,
active && styles.pillActive,
style,
]}
>
<Text style={[styles.pillText, { color: textColor }]}>{label}</Text>
</Pressable>
);
}
/**
* Kwadratowy przycisk z ikona zamiast pigulki z napisem. `badge` to licznik w rogu
* (np. liczba aktywnych filtrow), pokazywany tylko gdy > 0.
*/
export function IconButton({
icon,
active,
badge,
onPress,
accessibilityLabel,
}: {
icon: React.ComponentProps<typeof MaterialCommunityIcons>['name'];
active?: boolean;
badge?: number;
onPress?: () => void;
accessibilityLabel: string;
}) {
return (
<Pressable
onPress={onPress}
hitSlop={6}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
style={[styles.iconBtn, active && styles.pillActive]}
>
<MaterialCommunityIcons name={icon} size={22} color={active ? theme.accentText : theme.fg} />
{badge && badge > 0 ? (
<View style={styles.badge}>
<Text style={styles.badgeText}>{badge}</Text>
</View>
) : null}
</Pressable>
);
}
/**
* Wejscie do ustawien. Gorna belka znikla (2026-09-07), wiec kolo zebate siedzi w
* wierszu narzedzi kazdej sekcji; darowizna i zmiana backendu sa w ustawieniach.
*/
export function SettingsButton() {
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
return (
<IconButton
icon="cog-outline"
accessibilityLabel="Settings"
onPress={() => navigation.navigate('AppLockSettings')}
/>
);
}
/**
* Ikona w prawym rogu natywnego naglowka (ulubione, ukryj). Jeden rozmiar i kolor
* na wszystkich ekranach zamiast znakow ★ i 🚫 w roznych rozmiarach.
*/
export function HeaderIconButton({
icon,
active,
muted,
onPress,
disabled,
accessibilityLabel,
}: {
icon: React.ComponentProps<typeof MaterialCommunityIcons>['name'];
active?: boolean;
/** Drugorzedna akcja (np. ukryj): szary zamiast koloru tekstu. */
muted?: boolean;
onPress?: () => void;
disabled?: boolean;
accessibilityLabel: string;
}) {
const color = active ? theme.accentText : muted ? theme.mutedDim : theme.muted;
return (
<Pressable
onPress={onPress}
disabled={disabled}
hitSlop={12}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
style={({ pressed }) => [styles.headerIcon, pressed && { opacity: 0.6 }]}
>
<MaterialCommunityIcons name={icon} size={24} color={color} />
</Pressable>
);
}
/** Wiersz narzędzi: pole + przyciski, jednakowy odstęp i margines na każdym ekranie. */
export function Toolbar({ children }: { children: React.ReactNode }) {
return <View style={styles.toolbar}>{children}</View>;
}
export interface SegmentTab<T extends string> {
key: T;
label: string;
/** Liczba po etykiecie (np. 200+), pomijana gdy pusta albo 0. */
count?: number | string;
/** Nowe pozycje: czerwony licznik w rogu, jak na IconButton. */
badge?: number;
}
/**
* Zakladki ekranu w jednym wierszu o wysokosci pola wyszukiwania (2026-09-15). Wczesniej
* ekrany podrzedne mialy pod naglowkiem osobny wiersz zakladek, pod nim wiersz z
* licznikiem i ikonami, a ulubione jeszcze etykiete i podpowiedz, czyli trzy albo cztery
* linie zanim zaczynala sie lista. Teraz zakladki siedza w Toolbarze obok ikon.
*/
export function SegmentTabs<T extends string>({
tabs,
value,
onChange,
style,
}: {
tabs: SegmentTab<T>[];
value: T;
onChange: (key: T) => void;
style?: StyleProp<ViewStyle>;
}) {
return (
<View style={[styles.segWrap, style]} accessibilityRole="tablist">
{tabs.map((t) => {
const on = t.key === value;
const showCount = t.count !== undefined && t.count !== 0 && t.count !== '';
return (
<Pressable
key={t.key}
onPress={() => onChange(t.key)}
accessibilityRole="tab"
accessibilityState={{ selected: on }}
accessibilityLabel={showCount ? `${t.label}, ${t.count}` : t.label}
style={[styles.seg, on && styles.segActive]}
>
<Text numberOfLines={1} style={[styles.segText, on && styles.segTextActive]}>
{t.label}
</Text>
{showCount ? (
<Text numberOfLines={1} style={styles.segCount}>
{t.count}
</Text>
) : null}
{t.badge && t.badge > 0 ? (
<View style={styles.badge}>
<Text style={styles.badgeText}>+{t.badge}</Text>
</View>
) : null}
</Pressable>
);
})}
</View>
);
}
const styles = StyleSheet.create({
toolbar: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 12 },
headerIcon: { paddingHorizontal: 6, paddingVertical: 4 },
iconBtn: {
width: CONTROL_H,
height: CONTROL_H,
borderRadius: RADIUS,
backgroundColor: theme.card,
borderColor: theme.borderStrong,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
badge: {
position: 'absolute',
top: -6,
right: -6,
minWidth: 18,
height: 18,
borderRadius: 9,
paddingHorizontal: 4,
backgroundColor: theme.accent,
alignItems: 'center',
justifyContent: 'center',
},
badgeText: { color: theme.fg, fontSize: 11, fontFamily: fonts.display },
search: {
flex: 1,
height: CONTROL_H,
backgroundColor: theme.card,
borderColor: theme.borderStrong,
borderWidth: 1,
borderRadius: RADIUS,
color: theme.fg,
paddingHorizontal: 14,
fontFamily: fonts.body,
fontSize: 15,
},
searchFocused: { borderColor: theme.borderFocus },
pill: {
backgroundColor: theme.card,
borderColor: theme.border,
borderWidth: 1,
borderRadius: RADIUS,
alignItems: 'center',
justifyContent: 'center',
},
pillTall: { height: CONTROL_H, paddingHorizontal: 16 },
pillCompact: { height: 36, paddingHorizontal: 14 },
pillActive: { borderColor: theme.accentText, backgroundColor: `${theme.accent}1A` },
pillText: { fontFamily: fonts.medium, fontSize: 14 },
segWrap: {
flex: 1,
height: CONTROL_H,
flexDirection: 'row',
padding: 3,
gap: 3,
backgroundColor: theme.card,
borderColor: theme.borderStrong,
borderWidth: 1,
borderRadius: RADIUS,
},
seg: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 5,
paddingHorizontal: 4,
borderRadius: RADIUS - 3,
borderWidth: 1,
borderColor: 'transparent',
},
segActive: { borderColor: theme.accentText, backgroundColor: `${theme.accent}1A` },
segText: { fontFamily: fonts.medium, fontSize: 14, color: theme.muted, flexShrink: 1 },
segTextActive: { color: theme.accentText },
segCount: { fontFamily: fonts.body, fontSize: 12, color: theme.mutedDim },
});