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>
This commit is contained in:
goon-foss 2026-09-15 15:37:53 +02:00
parent 37e6b7f03c
commit 7ed878ce16
12 changed files with 316 additions and 598 deletions

View file

@ -405,8 +405,9 @@ export class GoonClient {
}); });
} }
async listFavorites(): Promise<FavoriteListOut> { /** `counts: false` = bez liczników +N (tylko stan gwiazdki, serwer odpowiada od razu). */
return this.request('/favorites'); async listFavorites(opts: { counts?: boolean } = {}): Promise<FavoriteListOut> {
return this.request(opts.counts === false ? '/favorites?counts=false' : '/favorites');
} }
async addFavorite(performerId: string): Promise<FavoriteAddOut> { async addFavorite(performerId: string): Promise<FavoriteAddOut> {
@ -428,8 +429,8 @@ export class GoonClient {
await this.request(`/favorites/${performerId}/seen`, { method: 'POST' }); await this.request(`/favorites/${performerId}/seen`, { method: 'POST' });
} }
async listFavoriteStudios(): Promise<FavoriteStudioListOut> { async listFavoriteStudios(opts: { counts?: boolean } = {}): Promise<FavoriteStudioListOut> {
return this.request('/favorites/studios'); return this.request(opts.counts === false ? '/favorites/studios?counts=false' : '/favorites/studios');
} }
async addFavoriteStudio(studioId: string): Promise<FavoriteStudioAddOut> { async addFavoriteStudio(studioId: string): Promise<FavoriteStudioAddOut> {

View file

@ -35,6 +35,23 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-09-15d',
date: 'September 2026',
items: [
'Edge: the bottom bar slides away when you swipe to the next clip and comes back when you swipe back, so it no longer covers the caption, tags and progress line.',
'Edge: the bug report button is hidden there, it was sitting on top of the not-interested button. Reports still work from every other tab.',
],
minRuntime: '1.4',
},
{
id: '2026-09-15c',
date: 'September 2026',
items: [
'Favorites, performer and studio pages, Performers and Tags: tabs, sorting and buttons now share one row under the title instead of stacking three or four lines above the list.',
'Favorites and similar scenes load much faster, and opening a performer or studio no longer waits for the new-scene counters.',
],
},
{ {
id: '2026-09-15b', id: '2026-09-15b',
date: 'September 2026', date: 'September 2026',

View file

@ -106,19 +106,27 @@ export function BugReportFAB({ client, appVersion, navRef }: Props) {
}, [unseen, client]); }, [unseen, client]);
// Re-render na zmianę current route, żeby FAB pojawiał/znikał per-screen. // Re-render na zmianę current route, żeby FAB pojawiał/znikał per-screen.
const [currentRoute, setCurrentRoute] = useState<string | null>(null); const [currentRoute, setCurrentRoute] = useState<string | null>(null);
const [currentTab, setCurrentTab] = useState<string | null>(null);
React.useEffect(() => { React.useEffect(() => {
if (!navRef) return; if (!navRef) return;
const unsub = navRef.addListener('state', () => { const unsub = navRef.addListener('state', () => {
try { try {
const r = navRef.isReady() ? navRef.getCurrentRoute() : null; const r = navRef.isReady() ? navRef.getCurrentRoute() : null;
setCurrentRoute(r?.name ?? null); setCurrentRoute(r?.name ?? null);
const tab = (r?.params as { tab?: unknown } | undefined)?.tab;
setCurrentTab(typeof tab === 'string' ? tab : null);
} catch { } catch {
setCurrentRoute(null); setCurrentRoute(null);
setCurrentTab(null);
} }
}); });
return unsub; return unsub;
}, [navRef]); }, [navRef]);
const hidden = currentRoute !== null && FAB_HIDDEN_SCREENS.has(currentRoute); // Edge to wideo na caly ekran z kolumna przyciskow po prawej, a FAB lezal dokladnie na
// „Not interested" (2026-09-15). Zgloszenie da sie wyslac z kazdej innej zakladki.
const hidden =
currentRoute !== null &&
(FAB_HIDDEN_SCREENS.has(currentRoute) || (currentRoute === 'Scenes' && currentTab === 'Edge'));
// Na ekranie-gospodarzu (trasa 'Scenes') na dole wisi pasek zakladek, FAB idzie // Na ekranie-gospodarzu (trasa 'Scenes') na dole wisi pasek zakladek, FAB idzie
// nad niego; na pozostalych ekranach zostaje przy krawedzi. // nad niego; na pozostalych ekranach zostaje przy krawedzi.
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();

View file

@ -11,6 +11,11 @@
* double-tap → serce (ulubione) + wybuch serca + haptyka, * double-tap → serce (ulubione) + wybuch serca + haptyka,
* przytrzymanie → 2x do puszczenia, * przytrzymanie → 2x do puszczenia,
* przesuniecie w lewo → arkusz „nie interesuje" (pion zostaje dla pagera). * przesuniecie w lewo → arkusz „nie interesuje" (pion zostaje dla pagera).
*
* Dolny pasek zakladek wisi nad feedem. Podpis, kolumna przyciskow i pasek postepu jada
* razem z nim (ta sama wartosc Animated `barHidden`): gdy pasek jest widoczny, stoja nad
* nim, gdy sie schowa, schodza na dol ekranu. Wczesniej pasek zaslanial podpis, tagi i
* postep (uwaga Jana 2026-09-15).
*/ */
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
@ -19,7 +24,9 @@ import { VideoView } from 'expo-video';
import React from 'react'; import React from 'react';
import { Animated, Pressable, StyleSheet, Text, View } from 'react-native'; import { Animated, Pressable, StyleSheet, Text, View } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { BOTTOM_BAR_HEIGHT, barHidden } from '../../lib/bottomBar';
import { fonts, theme } from '../../theme'; import { fonts, theme } from '../../theme';
import type { ClipFeedItem } from '../../types'; import type { ClipFeedItem } from '../../types';
@ -40,6 +47,15 @@ function ClipCellBase({ item, active, player, muted, liked, onToggleMute, onLike
const [progress, setProgress] = React.useState(0); const [progress, setProgress] = React.useState(0);
const [ready, setReady] = React.useState(false); const [ready, setReady] = React.useState(false);
const heart = React.useRef(new Animated.Value(0)).current; const heart = React.useRef(new Animated.Value(0)).current;
const insets = useSafeAreaInsets();
const lift = React.useMemo(
() =>
barHidden.interpolate({
inputRange: [0, 1],
outputRange: [-(BOTTOM_BAR_HEIGHT + insets.bottom), -insets.bottom],
}),
[insets.bottom],
);
// Postep i gotowosc tylko dla aktywnej strony z playerem. // Postep i gotowosc tylko dla aktywnej strony z playerem.
React.useEffect(() => { React.useEffect(() => {
@ -158,61 +174,63 @@ function ClipCellBase({ item, active, player, muted, liked, onToggleMute, onLike
<MaterialCommunityIcons name="heart" size={120} color="#fff" /> <MaterialCommunityIcons name="heart" size={120} color="#fff" />
</Animated.View> </Animated.View>
{/* Kolumna przyciskow */} <Animated.View style={[StyleSheet.absoluteFill, { transform: [{ translateY: lift }] }]} pointerEvents="box-none">
<View style={styles.rail} pointerEvents="box-none"> {/* Kolumna przyciskow */}
<Pressable <View style={styles.rail} pointerEvents="box-none">
onPress={() => { <Pressable
burst(); onPress={() => {
onLike(); burst();
}} onLike();
style={styles.railBtn} }}
accessibilityLabel={liked ? 'Remove from favorites' : 'Add to favorites'} style={styles.railBtn}
> accessibilityLabel={liked ? 'Remove from favorites' : 'Add to favorites'}
<MaterialCommunityIcons name={liked ? 'heart' : 'heart-outline'} size={30} color={liked ? theme.accentText : '#fff'} /> >
</Pressable> <MaterialCommunityIcons name={liked ? 'heart' : 'heart-outline'} size={30} color={liked ? theme.accentText : '#fff'} />
<Pressable onPress={onToggleMute} style={styles.railBtn} accessibilityLabel={muted ? 'Unmute' : 'Mute'}> </Pressable>
<MaterialCommunityIcons <Pressable onPress={onToggleMute} style={styles.railBtn} accessibilityLabel={muted ? 'Unmute' : 'Mute'}>
name={clip.has_audio ? (muted ? 'volume-off' : 'volume-high') : 'volume-variant-off'} <MaterialCommunityIcons
size={28} name={clip.has_audio ? (muted ? 'volume-off' : 'volume-high') : 'volume-variant-off'}
color={clip.has_audio ? '#fff' : '#9a9a9a'} size={28}
/> color={clip.has_audio ? '#fff' : '#9a9a9a'}
</Pressable> />
<Pressable onPress={onNotInterested} style={styles.railBtn} accessibilityLabel="Not interested"> </Pressable>
<MaterialCommunityIcons name="thumb-down-outline" size={26} color="#fff" /> <Pressable onPress={onNotInterested} style={styles.railBtn} accessibilityLabel="Not interested">
</Pressable> <MaterialCommunityIcons name="thumb-down-outline" size={26} color="#fff" />
</View> </Pressable>
{/* Podpis: tworca, tytul, tagi */}
<View style={styles.caption} pointerEvents="box-none">
<View style={styles.creatorRow}>
<Text style={styles.creator} numberOfLines={1}>
{clip.creator ? `@${clip.creator}` : clip.source}
</Text>
{clip.creator_verified ? (
<MaterialCommunityIcons name="check-decagram" size={15} color={theme.accentSecondary} style={{ marginLeft: 4 }} />
) : null}
{item.is_fresh ? <Text style={styles.fresh}>NEW</Text> : null}
</View> </View>
{clip.title ? (
<Text style={styles.title} numberOfLines={2}>
{clip.title}
</Text>
) : null}
{tags.length ? (
<View style={styles.tagsRow}>
{tags.map((t) => (
<Pressable key={t} onPress={() => onTagPress?.(t)} style={styles.tagChip}>
<Text style={styles.tagText}>#{t}</Text>
</Pressable>
))}
</View>
) : null}
</View>
{/* Pasek postepu */} {/* Podpis: tworca, tytul, tagi */}
<View style={styles.progressTrack} pointerEvents="none"> <View style={styles.caption} pointerEvents="box-none">
<View style={[styles.progressFill, { width: `${Math.round(progress * 100)}%` }]} /> <View style={styles.creatorRow}>
</View> <Text style={styles.creator} numberOfLines={1}>
{clip.creator ? `@${clip.creator}` : clip.source}
</Text>
{clip.creator_verified ? (
<MaterialCommunityIcons name="check-decagram" size={15} color={theme.accentSecondary} style={{ marginLeft: 4 }} />
) : null}
{item.is_fresh ? <Text style={styles.fresh}>NEW</Text> : null}
</View>
{clip.title ? (
<Text style={styles.title} numberOfLines={2}>
{clip.title}
</Text>
) : null}
{tags.length ? (
<View style={styles.tagsRow}>
{tags.map((t) => (
<Pressable key={t} onPress={() => onTagPress?.(t)} style={styles.tagChip}>
<Text style={styles.tagText}>#{t}</Text>
</Pressable>
))}
</View>
) : null}
</View>
{/* Pasek postepu */}
<View style={styles.progressTrack} pointerEvents="none">
<View style={[styles.progressFill, { width: `${Math.round(progress * 100)}%` }]} />
</View>
</Animated.View>
</View> </View>
</GestureDetector> </GestureDetector>
); );
@ -231,7 +249,8 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
rail: { position: 'absolute', right: 8, bottom: 96, alignItems: 'center' }, // Nad dolnym paskiem pilnuje tego `lift`, wiec kolumna stoi na wysokosci podpisu.
rail: { position: 'absolute', right: 8, bottom: 20, alignItems: 'center' },
railBtn: { railBtn: {
width: 48, width: 48,
height: 48, height: 48,

View file

@ -23,6 +23,7 @@ import { SceneTile, sceneGridProps } from '../components/SceneTile';
import { usePreferences } from '../PreferencesContext'; import { usePreferences } from '../PreferencesContext';
import type { RootStackParamList } from '../navigation'; import type { RootStackParamList } from '../navigation';
import { theme } from '../theme'; import { theme } from '../theme';
import { SegmentTabs, Toolbar } from '../ui';
import type { FavoriteMovieOut, FavoriteOut, FavoriteStudioOut } from '../types'; import type { FavoriteMovieOut, FavoriteOut, FavoriteStudioOut } from '../types';
type Tab = 'scenes' | 'performers' | 'studios' | 'movies'; type Tab = 'scenes' | 'performers' | 'studios' | 'movies';
@ -115,12 +116,6 @@ export function FavoritesScreen() {
}; };
// performers/studios mają new_total badge; scenes/movies nie (single entity). // performers/studios mają new_total badge; scenes/movies nie (single entity).
const activeData =
tab === 'performers'
? performersQuery.data
: tab === 'studios'
? studiosQuery.data
: null;
const activeQuery = const activeQuery =
tab === 'performers' tab === 'performers'
? performersQuery ? performersQuery
@ -129,7 +124,6 @@ export function FavoritesScreen() {
: tab === 'movies' : tab === 'movies'
? moviesQuery ? moviesQuery
: scenesQuery; : scenesQuery;
const headerCount = activeData?.total ?? (activeQuery.data as { total?: number } | undefined)?.total ?? 0;
const isLoading = activeQuery.isLoading; const isLoading = activeQuery.isLoading;
const isRefetching = activeQuery.isRefetching; const isRefetching = activeQuery.isRefetching;
const error = activeQuery.error; const error = activeQuery.error;
@ -137,47 +131,21 @@ export function FavoritesScreen() {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View style={styles.headerRow}> {/* Jeden wiersz zakładek (2026-09-15). Wcześniej pod natywnym nagłówkiem
<Text style={styles.headerLabel}>Favorites</Text> "Favorites" stała druga etykieta FAVORITES z licznikiem, wiersz zakładek i
{activeData && activeData.new_total > 0 ? ( podpowiedź, czyli cztery linie przed listą. Podpowiedź zeszła pod listę. */}
<View style={styles.totalBadge}> <Toolbar>
<Text style={styles.totalBadgeText}>+{activeData.new_total} new</Text> <SegmentTabs
</View> tabs={[
) : ( { key: 'scenes', label: 'Scenes' },
<Text style={styles.headerCount}>{headerCount}</Text> { key: 'performers', label: 'Performers', badge: performersQuery.data?.new_total ?? 0 },
)} { key: 'studios', label: 'Studios', badge: studiosQuery.data?.new_total ?? 0 },
</View> { key: 'movies', label: 'Movies' },
]}
<View style={styles.tabRow}> value={tab}
<TabButton onChange={setTab}
label="Scenes"
active={tab === 'scenes'}
newCount={0}
onPress={() => setTab('scenes')}
/> />
<TabButton </Toolbar>
label="Performers"
active={tab === 'performers'}
newCount={performersQuery.data?.new_total ?? 0}
onPress={() => setTab('performers')}
/>
<TabButton
label="Studios"
active={tab === 'studios'}
newCount={studiosQuery.data?.new_total ?? 0}
onPress={() => setTab('studios')}
/>
<TabButton
label="Movies"
active={tab === 'movies'}
newCount={0}
onPress={() => setTab('movies')}
/>
</View>
<Text style={styles.hint}>
tap to open scenes (resets the new badge) · long-press to remove
</Text>
{isLoading && <ActivityIndicator color={theme.fg} style={{ marginTop: 24 }} />} {isLoading && <ActivityIndicator color={theme.fg} style={{ marginTop: 24 }} />}
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>} {error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
@ -221,6 +189,7 @@ export function FavoritesScreen() {
</Text> </Text>
) : null ) : null
} }
ListFooterComponent={scenesQuery.data?.items.length ? <Text style={styles.hint}>long-press to remove</Text> : null}
contentContainerStyle={{ paddingBottom: 24 }} contentContainerStyle={{ paddingBottom: 24 }}
/> />
) : tab === 'movies' ? ( ) : tab === 'movies' ? (
@ -260,6 +229,7 @@ export function FavoritesScreen() {
</Text> </Text>
) : null ) : null
} }
ListFooterComponent={moviesQuery.data?.items.length ? <Text style={styles.hint}>long-press to remove</Text> : null}
contentContainerStyle={{ paddingBottom: 24 }} contentContainerStyle={{ paddingBottom: 24 }}
/> />
) : tab === 'performers' ? ( ) : tab === 'performers' ? (
@ -306,6 +276,7 @@ export function FavoritesScreen() {
</Text> </Text>
) : null ) : null
} }
ListFooterComponent={performersQuery.data?.items.length ? <Text style={styles.hint}>tap to open (clears the new badge) · long-press to remove</Text> : null}
contentContainerStyle={{ paddingBottom: 24 }} contentContainerStyle={{ paddingBottom: 24 }}
/> />
) : ( ) : (
@ -351,6 +322,7 @@ export function FavoritesScreen() {
</Text> </Text>
) : null ) : null
} }
ListFooterComponent={studiosQuery.data?.items.length ? <Text style={styles.hint}>tap to open (clears the new badge) · long-press to remove</Text> : null}
contentContainerStyle={{ paddingBottom: 24 }} contentContainerStyle={{ paddingBottom: 24 }}
/> />
)} )}
@ -358,29 +330,6 @@ export function FavoritesScreen() {
); );
} }
function TabButton({
label,
active,
newCount,
onPress,
}: {
label: string;
active: boolean;
newCount: number;
onPress: () => void;
}) {
return (
<Pressable style={[styles.tabBtn, active && styles.tabBtnActive]} onPress={onPress}>
<Text style={[styles.tabBtnText, active && styles.tabBtnTextActive]}>{label}</Text>
{newCount > 0 ? (
<View style={styles.tabBadge}>
<Text style={styles.tabBadgeText}>+{newCount}</Text>
</View>
) : null}
</Pressable>
);
}
function initials(name: string): string { function initials(name: string): string {
const parts = name.trim().split(/\s+/); const parts = name.trim().split(/\s+/);
const first = parts[0]?.[0] ?? ''; const first = parts[0]?.[0] ?? '';
@ -520,63 +469,7 @@ function StudioRow({
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 16, paddingTop: 12 }, container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 16, paddingTop: 12 },
headerRow: { hint: { color: theme.mutedDim, fontSize: 11, textAlign: 'center', marginTop: 4, marginBottom: 8 },
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingBottom: 4,
},
headerLabel: {
color: theme.muted,
fontSize: 12,
textTransform: 'uppercase',
letterSpacing: 1.2,
fontWeight: '700',
},
headerCount: { color: theme.fg, fontSize: 22, fontWeight: '800' },
totalBadge: {
backgroundColor: theme.accent,
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 10,
shadowColor: theme.accent,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.5,
shadowRadius: 6,
elevation: 3,
},
totalBadgeText: {
color: theme.fg,
fontWeight: '800',
fontSize: 13,
letterSpacing: 0.3,
},
tabRow: { flexDirection: 'row', gap: 8, marginTop: 10, marginBottom: 4 },
tabBtn: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
paddingVertical: 8,
borderRadius: 8,
backgroundColor: theme.card,
borderColor: theme.border,
borderWidth: 1,
},
tabBtnActive: { borderColor: theme.accentText, backgroundColor: theme.bgElevated },
tabBtnText: { color: theme.muted, fontWeight: '600', fontSize: 14 },
tabBtnTextActive: { color: theme.fg },
tabBadge: {
backgroundColor: theme.accent,
paddingHorizontal: 6,
paddingVertical: 1,
borderRadius: 4,
},
tabBadgeText: { color: theme.bg, fontWeight: '800', fontSize: 11 },
hint: { color: theme.mutedDim, fontSize: 11, marginTop: 8, marginBottom: 12 },
gridRow: { gap: 10, marginBottom: 14 }, gridRow: { gap: 10, marginBottom: 14 },

View file

@ -12,8 +12,6 @@ import {
ActivityIndicator, ActivityIndicator,
Alert, Alert,
FlatList, FlatList,
Pressable,
ScrollView,
StyleSheet, StyleSheet,
Text, Text,
View, View,
@ -28,7 +26,7 @@ import { ErrorBoundary } from '../ErrorBoundary';
import type { RootStackParamList } from '../navigation'; import type { RootStackParamList } from '../navigation';
import { theme } from '../theme'; import { theme } from '../theme';
import type { SceneOut } from '../types'; import type { SceneOut } from '../types';
import { HeaderIconButton, IconButton, Toolbar } from '../ui'; import { HeaderIconButton, IconButton, SegmentTabs, Toolbar } from '../ui';
type Tab = 'scenes' | 'movies'; type Tab = 'scenes' | 'movies';
@ -50,9 +48,12 @@ export function PerformerScenesScreen() {
const [tab, setTab] = React.useState<Tab>('scenes'); const [tab, setTab] = React.useState<Tab>('scenes');
// Sam stan gwiazdki, bez liczników +N: pełne /favorites liczy nowe sceny wszystkich
// ulubionych (sekundy przy setkach), a ten ekran potrzebuje tylko listy id.
// Klucz pod ['favorites'], więc każde invalidateQueries(['favorites']) odświeża też to.
const favoritesQuery = useQuery({ const favoritesQuery = useQuery({
queryKey: ['favorites'], queryKey: ['favorites', 'ids'],
queryFn: () => client.listFavorites(), queryFn: () => client.listFavorites({ counts: false }),
staleTime: 30_000, staleTime: 30_000,
}); });
const isFavorite = !!favoritesQuery.data?.items.find((f) => f.performer_id === id); const isFavorite = !!favoritesQuery.data?.items.find((f) => f.performer_id === id);
@ -240,21 +241,40 @@ export function PerformerScenesScreen() {
setTab(next); setTab(next);
}, [id, name, tab]); }, [id, name, tab]);
// Jeden wiersz (2026-09-15): zakładki z licznikami po lewej, ikony sceny po prawej.
// Wcześniej zakładki, pod nimi wiersz "200+ scenes" z ikonami, czyli trzy linie
// razem z nagłówkiem, a licznik stał dwa razy.
const tabRow = ( const tabRow = (
<View style={styles.tabRow}> <Toolbar>
<TabButton <SegmentTabs
label="Scenes" tabs={[
count={scenesTotal} { key: 'scenes', label: 'Scenes', count: scenesQuery.data ? `${scenesTotal}${scenesSuffix}` : undefined },
active={tab === 'scenes'} { key: 'movies', label: 'Movies', count: moviesTotal },
onPress={() => handleTabSwitch('scenes')} ]}
value={tab}
onChange={handleTabSwitch}
/> />
<TabButton {tab === 'scenes' ? (
label="Movies" <>
count={moviesTotal} {refreshMutation.isPending ? (
active={tab === 'movies'} <ActivityIndicator color={theme.accentText} style={{ width: 44 }} />
onPress={() => handleTabSwitch('movies')} ) : (
/> <IconButton
</View> icon="magnify-scan"
accessibilityLabel="Search more scenes"
onPress={() => refreshMutation.mutate()}
/>
)}
<IconButton
icon="filter-variant"
accessibilityLabel="Filters"
active={filterActiveCount > 0}
badge={filterActiveCount}
onPress={() => setFilterOpen(true)}
/>
</>
) : null}
</Toolbar>
); );
return ( return (
@ -286,36 +306,6 @@ export function PerformerScenesScreen() {
if (filter.sort === 'random') setSeed(Math.random().toString(36).slice(2) + Date.now().toString(36)); if (filter.sort === 'random') setSeed(Math.random().toString(36).slice(2) + Date.now().toString(36));
else scenesQuery.refetch(); else scenesQuery.refetch();
}} }}
ListHeaderComponent={
<View>
{/* Jeden wiersz (2026-09-07): licznik scen po lewej, po prawej ikony
„szukaj wiecej" i filtr z licznikiem. Chipsy tagow pod spodem
zniknely, tagi sa w filtrze. */}
<Toolbar>
<Text style={[styles.subtitle, { flex: 1 }]} numberOfLines={1}>
{scenesQuery.data
? `${scenesTotal}${scenesSuffix} ${scenesTotal === 1 ? 'scene' : 'scenes'}`
: ' '}
</Text>
{refreshMutation.isPending ? (
<ActivityIndicator color={theme.accentText} style={{ width: 44 }} />
) : (
<IconButton
icon="magnify-scan"
accessibilityLabel="Search more scenes"
onPress={() => refreshMutation.mutate()}
/>
)}
<IconButton
icon="filter-variant"
accessibilityLabel="Filters"
active={filterActiveCount > 0}
badge={filterActiveCount}
onPress={() => setFilterOpen(true)}
/>
</Toolbar>
</View>
}
ListEmptyComponent={ ListEmptyComponent={
!scenesQuery.isLoading ? <Text style={styles.muted}>no scenes</Text> : null !scenesQuery.isLoading ? <Text style={styles.muted}>no scenes</Text> : null
} }
@ -363,15 +353,6 @@ export function PerformerScenesScreen() {
}} }}
refreshing={moviesQuery.isRefetching} refreshing={moviesQuery.isRefetching}
onRefresh={moviesQuery.refetch} onRefresh={moviesQuery.refetch}
ListHeaderComponent={
<View style={styles.header}>
<Text style={styles.subtitle}>
{moviesQuery.data
? `${moviesTotal} ${moviesTotal === 1 ? 'film' : 'films'}`
: ' '}
</Text>
</View>
}
ListEmptyComponent={ ListEmptyComponent={
!moviesQuery.isLoading ? ( !moviesQuery.isLoading ? (
<Text style={styles.muted}>no movies for this performer</Text> <Text style={styles.muted}>no movies for this performer</Text>
@ -399,31 +380,6 @@ export function PerformerScenesScreen() {
); );
} }
function TabButton({
label,
count,
active,
onPress,
}: {
label: string;
count: number;
active: boolean;
onPress: () => void;
}) {
return (
<Pressable style={[styles.tabBtn, active && styles.tabBtnActive]} onPress={onPress}>
<Text style={[styles.tabBtnText, active && styles.tabBtnTextActive]}>
{label}
</Text>
{count > 0 ? (
<View style={styles.tabCount}>
<Text style={styles.tabCountText}>{count}</Text>
</View>
) : null}
</Pressable>
);
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 12, paddingTop: 8 }, container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 12, paddingTop: 8 },
gridRow: { gap: 10, marginBottom: 14 }, gridRow: { gap: 10, marginBottom: 14 },
@ -475,27 +431,4 @@ const styles = StyleSheet.create({
tagChipActive: { backgroundColor: theme.accent, borderColor: theme.accentText }, tagChipActive: { backgroundColor: theme.accent, borderColor: theme.accentText },
tagChipText: { color: theme.muted, fontSize: 12, fontWeight: '600' }, tagChipText: { color: theme.muted, fontSize: 12, fontWeight: '600' },
tagChipTextActive: { color: theme.fg, fontWeight: '700' }, tagChipTextActive: { color: theme.fg, fontWeight: '700' },
tabRow: { flexDirection: 'row', gap: 8, marginBottom: 8 },
tabBtn: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
paddingVertical: 8,
borderRadius: 8,
backgroundColor: theme.card,
borderColor: theme.border,
borderWidth: 1,
},
tabBtnActive: { borderColor: theme.accentText, backgroundColor: theme.bgElevated },
tabBtnText: { color: theme.muted, fontWeight: '600', fontSize: 14 },
tabBtnTextActive: { color: theme.fg },
tabCount: {
backgroundColor: theme.border,
paddingHorizontal: 6,
paddingVertical: 1,
borderRadius: 4,
},
tabCountText: { color: theme.muted, fontWeight: '700', fontSize: 11 },
}); });

View file

@ -12,12 +12,12 @@ import {
Pressable, Pressable,
StyleSheet, StyleSheet,
Text, Text,
TextInput,
View, View,
} from 'react-native'; } from 'react-native';
import { useClient } from '../ClientContext'; import { useClient } from '../ClientContext';
import type { RootStackParamList } from '../navigation'; import type { RootStackParamList } from '../navigation';
import { theme } from '../theme'; import { theme } from '../theme';
import { SearchInput, SegmentTabs, Toolbar } from '../ui';
import type { PerformerCount } from '../types'; import type { PerformerCount } from '../types';
type Order = 'scene_count' | 'name'; type Order = 'scene_count' | 'name';
@ -29,7 +29,6 @@ export function PerformersScreen() {
const [q, setQ] = useState(''); const [q, setQ] = useState('');
const [debouncedQ, setDebouncedQ] = useState(''); const [debouncedQ, setDebouncedQ] = useState('');
const [order, setOrder] = useState<Order>('scene_count'); const [order, setOrder] = useState<Order>('scene_count');
const [searchFocused, setSearchFocused] = useState(false);
const route = useRoute<RouteProp<RootStackParamList, 'Performers'>>(); const route = useRoute<RouteProp<RootStackParamList, 'Performers'>>();
const vertical = route.params?.vertical; const vertical = route.params?.vertical;
@ -44,41 +43,23 @@ export function PerformersScreen() {
client.listPerformers({ q: debouncedQ || undefined, order, per_page: 200, vertical }), client.listPerformers({ q: debouncedQ || undefined, order, per_page: 200, vertical }),
}); });
const total = data?.items.length ?? 0;
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View style={styles.headerRow}> {/* Jeden wiersz (2026-09-15): pole i przełącznik sortowania obok siebie. Wcześniej
<Text style={styles.headerLabel}>Performers</Text> pod nagłówkiem była druga etykieta z licznikiem, podpowiedź, pole i osobny
<Text style={styles.headerCount}>{total}</Text> wiersz Top/A-Z, czyli cztery linie przed listą. */}
</View> <Toolbar>
<Text style={styles.hint}>tap a row → all their scenes</Text> <SearchInput value={q} onChangeText={setQ} placeholder="search performer…" />
<SegmentTabs
<View style={styles.toolbar}> style={styles.order}
<TextInput tabs={[
style={[styles.search, searchFocused && styles.searchFocused]} { key: 'scene_count', label: 'Top' },
value={q} { key: 'name', label: 'A-Z' },
onChangeText={setQ} ]}
onFocus={() => setSearchFocused(true)} value={order}
onBlur={() => setSearchFocused(false)} onChange={setOrder}
placeholder="search performer…"
placeholderTextColor={theme.mutedDim}
autoCapitalize="none"
/> />
</View> </Toolbar>
<View style={styles.segment}>
<SegButton
active={order === 'scene_count'}
onPress={() => setOrder('scene_count')}
label="Top"
/>
<SegButton
active={order === 'name'}
onPress={() => setOrder('name')}
label="A-Z"
/>
</View>
{isLoading && <ActivityIndicator color={theme.fg} style={{ marginTop: 24 }} />} {isLoading && <ActivityIndicator color={theme.fg} style={{ marginTop: 24 }} />}
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>} {error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
@ -109,27 +90,6 @@ export function PerformersScreen() {
); );
} }
function SegButton({
active,
onPress,
label,
}: {
active: boolean;
onPress: () => void;
label: string;
}) {
return (
<Pressable
onPress={onPress}
style={[styles.segButton, active && styles.segButtonActive]}
>
<Text style={[styles.segButtonText, active && styles.segButtonTextActive]}>
{label}
</Text>
</Pressable>
);
}
function initials(name: string): string { function initials(name: string): string {
const parts = name.trim().split(/\s+/); const parts = name.trim().split(/\s+/);
if (parts.length === 0) return '?'; if (parts.length === 0) return '?';
@ -176,64 +136,7 @@ function PerformerRow({
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 16, paddingTop: 12 }, container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 16, paddingTop: 12 },
headerRow: { order: { flex: 0, width: 124 },
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingBottom: 4,
},
headerLabel: {
color: theme.muted,
fontSize: 12,
textTransform: 'uppercase',
letterSpacing: 1.2,
fontWeight: '700',
},
headerCount: { color: theme.fg, fontSize: 22, fontWeight: '800' },
hint: { color: theme.mutedDim, fontSize: 11, marginBottom: 12 },
toolbar: { flexDirection: 'row', gap: 12, marginBottom: 10 },
search: {
flex: 1,
backgroundColor: theme.card,
borderColor: theme.borderStrong,
borderWidth: 1.5,
borderRadius: 12,
color: theme.fg,
padding: 12,
fontSize: 16,
},
searchFocused: { borderColor: theme.borderFocus },
segment: {
flexDirection: 'row',
backgroundColor: theme.bgElevated,
borderColor: theme.border,
borderWidth: 1,
borderRadius: 12,
padding: 4,
marginBottom: 14,
alignSelf: 'flex-start',
},
segButton: {
paddingHorizontal: 14,
paddingVertical: 6,
borderRadius: 8,
},
segButtonActive: {
backgroundColor: theme.accent,
shadowColor: theme.accent,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.4,
shadowRadius: 6,
elevation: 2,
},
segButtonText: {
color: theme.muted,
fontWeight: '700',
fontSize: 13,
},
segButtonTextActive: { color: theme.fg },
row: { row: {
flexDirection: 'row', flexDirection: 'row',

View file

@ -27,8 +27,8 @@ export function StudioMoviesScreen() {
const { slug, name, studioId } = route.params; const { slug, name, studioId } = route.params;
const favoritesQuery = useQuery({ const favoritesQuery = useQuery({
queryKey: ['favorites-studios'], queryKey: ['favorites-studios', 'ids'],
queryFn: () => client.listFavoriteStudios(), queryFn: () => client.listFavoriteStudios({ counts: false }),
staleTime: 30_000, staleTime: 30_000,
}); });
const isFavorite = !!favoritesQuery.data?.items.find((f) => f.studio_id === studioId); const isFavorite = !!favoritesQuery.data?.items.find((f) => f.studio_id === studioId);

View file

@ -17,7 +17,6 @@ import {
ActivityIndicator, ActivityIndicator,
Alert, Alert,
FlatList, FlatList,
Pressable,
StyleSheet, StyleSheet,
Text, Text,
View, View,
@ -30,7 +29,7 @@ import { useClient } from '../ClientContext';
import { usePreferences } from '../PreferencesContext'; import { usePreferences } from '../PreferencesContext';
import type { RootStackParamList } from '../navigation'; import type { RootStackParamList } from '../navigation';
import { theme } from '../theme'; import { theme } from '../theme';
import { HeaderIconButton } from '../ui'; import { HeaderIconButton, SegmentTabs, Toolbar } from '../ui';
import type { SceneOut } from '../types'; import type { SceneOut } from '../types';
type Tab = 'scenes' | 'movies'; type Tab = 'scenes' | 'movies';
@ -51,9 +50,11 @@ export function StudioScenesScreen() {
const { id: slug, name, seenSince, studioId, vertical } = route.params; const { id: slug, name, seenSince, studioId, vertical } = route.params;
const [tab, setTab] = React.useState<Tab>('scenes'); const [tab, setTab] = React.useState<Tab>('scenes');
// Sam stan gwiazdki (bez liczników +N, patrz PerformerScenesScreen). Klucz pod
// ['favorites-studios'], więc invalidate tamtego klucza odświeża też ten.
const favoritesQuery = useQuery({ const favoritesQuery = useQuery({
queryKey: ['favorites-studios'], queryKey: ['favorites-studios', 'ids'],
queryFn: () => client.listFavoriteStudios(), queryFn: () => client.listFavoriteStudios({ counts: false }),
staleTime: 30_000, staleTime: 30_000,
}); });
const isFavorite = !!favoritesQuery.data?.items.find((f) => f.studio_id === studioId); const isFavorite = !!favoritesQuery.data?.items.find((f) => f.studio_id === studioId);
@ -158,21 +159,18 @@ export function StudioScenesScreen() {
<View style={styles.container}> <View style={styles.container}>
{/* Pasek zakładek ZAWSZE widoczny, też podczas ładowania: gdyby siedział w {/* Pasek zakładek ZAWSZE widoczny, też podczas ładowania: gdyby siedział w
ListHeaderComponent, pierwsze wejście na Movies dałoby pusty ekran bez ListHeaderComponent, pierwsze wejście na Movies dałoby pusty ekran bez
drogi powrotnej (ta sama pułapka co w PerformerScenesScreen). */} drogi powrotnej (ta sama pułapka co w PerformerScenesScreen). Liczniki są
<View style={styles.tabRow}> w zakładkach, osobny wiersz "N scenes" pod nimi zniknął (2026-09-15). */}
<TabButton <Toolbar>
label="Scenes" <SegmentTabs
count={data?.total ?? 0} tabs={[
active={tab === 'scenes'} { key: 'scenes', label: 'Scenes', count: data ? `${data.total}${data.total_capped ? '+' : ''}` : undefined },
onPress={() => setTab('scenes')} { key: 'movies', label: 'Movies', count: moviesTotal },
]}
value={tab}
onChange={setTab}
/> />
<TabButton </Toolbar>
label="Movies"
count={moviesTotal}
active={tab === 'movies'}
onPress={() => setTab('movies')}
/>
</View>
{tab === 'scenes' ? ( {tab === 'scenes' ? (
<> <>
@ -190,13 +188,6 @@ export function StudioScenesScreen() {
)} )}
refreshing={isRefetching} refreshing={isRefetching}
onRefresh={refetch} onRefresh={refetch}
ListHeaderComponent={
<View style={styles.header}>
<Text style={styles.subtitle}>
{data ? `${data.total}${data.total_capped ? '+' : ''} ${data.total === 1 ? 'scene' : 'scenes'}` : ' '}
</Text>
</View>
}
ListEmptyComponent={!isLoading ? <Text style={styles.muted}>no scenes</Text> : null} ListEmptyComponent={!isLoading ? <Text style={styles.muted}>no scenes</Text> : null}
contentContainerStyle={{ paddingBottom: 24 }} contentContainerStyle={{ paddingBottom: 24 }}
/> />
@ -226,15 +217,6 @@ export function StudioScenesScreen() {
)} )}
refreshing={moviesQuery.isRefetching} refreshing={moviesQuery.isRefetching}
onRefresh={moviesQuery.refetch} onRefresh={moviesQuery.refetch}
ListHeaderComponent={
<View style={styles.header}>
<Text style={styles.subtitle}>
{moviesQuery.data
? `${moviesTotal} ${moviesTotal === 1 ? 'film' : 'films'}`
: ' '}
</Text>
</View>
}
ListEmptyComponent={ ListEmptyComponent={
!moviesQuery.isLoading ? ( !moviesQuery.isLoading ? (
<Text style={styles.muted}>no movies for this studio</Text> <Text style={styles.muted}>no movies for this studio</Text>
@ -249,61 +231,9 @@ export function StudioScenesScreen() {
); );
} }
function TabButton({
label,
count,
active,
onPress,
}: {
label: string;
count: number;
active: boolean;
onPress: () => void;
}) {
return (
<Pressable style={[styles.tabBtn, active && styles.tabBtnActive]} onPress={onPress}>
<Text style={[styles.tabBtnText, active && styles.tabBtnTextActive]}>{label}</Text>
{count > 0 ? (
<View style={styles.tabCount}>
<Text style={styles.tabCountText}>{count}</Text>
</View>
) : null}
</Pressable>
);
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 12, paddingTop: 8 }, container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 12, paddingTop: 8 },
gridRow: { gap: 10, marginBottom: 14 }, gridRow: { gap: 10, marginBottom: 14 },
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
paddingHorizontal: 4,
},
subtitle: { color: theme.muted },
tabRow: { flexDirection: 'row', gap: 8, marginBottom: 10, paddingHorizontal: 4 },
tabBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingVertical: 6,
paddingHorizontal: 14,
borderRadius: 999,
borderWidth: 1,
borderColor: theme.border,
},
tabBtnActive: { borderColor: theme.accentText },
tabBtnText: { color: theme.muted, fontSize: 13 },
tabBtnTextActive: { color: theme.accentText, fontWeight: '700' },
tabCount: {
backgroundColor: theme.border,
borderRadius: 999,
paddingHorizontal: 6,
paddingVertical: 1,
},
tabCountText: { color: theme.muted, fontSize: 11 },
muted: { color: theme.muted, textAlign: 'center', marginTop: 24 }, muted: { color: theme.muted, textAlign: 'center', marginTop: 24 },
error: { color: theme.bad, padding: 12 }, error: { color: theme.bad, padding: 12 },
}); });

View file

@ -26,6 +26,7 @@ import { ActivityIndicator, AppState, Modal, Pressable, ScrollView, StyleSheet,
import { useClient } from '../ClientContext'; import { useClient } from '../ClientContext';
import { ClipCell } from '../components/swipe/ClipCell'; import { ClipCell } from '../components/swipe/ClipCell';
import { NATIVE_RUNTIME, isNewerVersion } from '../lib/appVersion'; import { NATIVE_RUNTIME, isNewerVersion } from '../lib/appVersion';
import { setBarHidden } from '../lib/bottomBar';
import { usePersistedState } from '../lib/persistedState'; import { usePersistedState } from '../lib/persistedState';
import { SwipeTelemetry, leaveKind, newSessionId } from '../lib/swipeTelemetry'; import { SwipeTelemetry, leaveKind, newSessionId } from '../lib/swipeTelemetry';
import { fonts, theme } from '../theme'; import { fonts, theme } from '../theme';
@ -99,6 +100,21 @@ export function SwipeScreen() {
} }
}, [index, items.length, feed.hasNextPage, feed.isFetchingNextPage, feed]); }, [index, items.length, feed.hasNextPage, feed.isFetchingNextPage, feed]);
// ---- dolny pasek ----------------------------------------------------------------------
// Jak na listach: przewijanie do przodu chowa pasek, cofniecie o klip go przywraca, na
// pierwszym klipie jest zawsze. Zeby wrocic do innych zakladek, wystarczy cofnac sie
// o jeden klip. Po wyjsciu z Edge pasek musi byc widoczny.
React.useEffect(() => () => setBarHidden(false), []);
const onPageSelected = React.useCallback(
(e: { nativeEvent: { position: number } }) => {
const next = e.nativeEvent.position;
if (next === 0 || next < index) setBarHidden(false);
else if (next > index) setBarHidden(true);
setIndex(next);
},
[index],
);
// ---- telemetria ---------------------------------------------------------------------- // ---- telemetria ----------------------------------------------------------------------
const telemetry = React.useRef<SwipeTelemetry | null>(null); const telemetry = React.useRef<SwipeTelemetry | null>(null);
if (!telemetry.current) telemetry.current = new SwipeTelemetry(client, newSessionId()); if (!telemetry.current) telemetry.current = new SwipeTelemetry(client, newSessionId());
@ -261,6 +277,7 @@ export function SwipeScreen() {
telemetry.current = new SwipeTelemetry(client, newSessionId()); telemetry.current = new SwipeTelemetry(client, newSessionId());
telemetry.current.start(); telemetry.current.start();
prevIndexRef.current = null; prevIndexRef.current = null;
setBarHidden(false);
setIndex(0); setIndex(0);
setSeedKey((k) => k + 1); setSeedKey((k) => k + 1);
}, [client]); }, [client]);
@ -317,7 +334,7 @@ export function SwipeScreen() {
initialPage={0} initialPage={0}
offscreenPageLimit={1} offscreenPageLimit={1}
overdrag overdrag
onPageSelected={(e: { nativeEvent: { position: number } }) => setIndex(e.nativeEvent.position)} onPageSelected={onPageSelected}
> >
{items.map((it, i) => ( {items.map((it, i) => (
<View key={it.clip.id} style={styles.page} collapsable={false}> <View key={it.clip.id} style={styles.page} collapsable={false}>

View file

@ -12,12 +12,12 @@ import {
Pressable, Pressable,
StyleSheet, StyleSheet,
Text, Text,
TextInput,
View, View,
} from 'react-native'; } from 'react-native';
import { useClient } from '../ClientContext'; import { useClient } from '../ClientContext';
import type { RootStackParamList } from '../navigation'; import type { RootStackParamList } from '../navigation';
import { theme } from '../theme'; import { theme } from '../theme';
import { SearchInput, SegmentTabs, Toolbar } from '../ui';
import type { TagCount } from '../types'; import type { TagCount } from '../types';
type Order = 'popular' | 'name'; type Order = 'popular' | 'name';
@ -31,7 +31,6 @@ export function TagsScreen() {
const [q, setQ] = useState(''); const [q, setQ] = useState('');
const [debouncedQ, setDebouncedQ] = useState(''); const [debouncedQ, setDebouncedQ] = useState('');
const [order, setOrder] = useState<Order>('popular'); const [order, setOrder] = useState<Order>('popular');
const [searchFocused, setSearchFocused] = useState(false);
React.useEffect(() => { React.useEffect(() => {
const t = setTimeout(() => setDebouncedQ(q), 350); const t = setTimeout(() => setDebouncedQ(q), 350);
@ -53,41 +52,23 @@ export function TagsScreen() {
}), }),
}); });
const total = data?.items.length ?? 0;
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View style={styles.headerRow}> {/* Jeden wiersz (2026-09-15): pole i przełącznik sortowania obok siebie. Wcześniej
<Text style={styles.headerLabel}>Tags</Text> pod nagłówkiem była druga etykieta z licznikiem, podpowiedź, pole i osobny
<Text style={styles.headerCount}>{total}</Text> wiersz Top/A-Z, czyli cztery linie przed listą. */}
</View> <Toolbar>
<Text style={styles.hint}>tap a chip → filter scenes by tag</Text> <SearchInput value={q} onChangeText={setQ} placeholder="search tag…" />
<SegmentTabs
<View style={styles.toolbar}> style={styles.order}
<TextInput tabs={[
style={[styles.search, searchFocused && styles.searchFocused]} { key: 'popular', label: 'Top' },
value={q} { key: 'name', label: 'A-Z' },
onChangeText={setQ} ]}
onFocus={() => setSearchFocused(true)} value={order}
onBlur={() => setSearchFocused(false)} onChange={setOrder}
placeholder="search tag…"
placeholderTextColor={theme.mutedDim}
autoCapitalize="none"
/> />
</View> </Toolbar>
<View style={styles.segment}>
<SegButton
active={order === 'popular'}
onPress={() => setOrder('popular')}
label="Top"
/>
<SegButton
active={order === 'name'}
onPress={() => setOrder('name')}
label="A-Z"
/>
</View>
{isLoading && <ActivityIndicator color={theme.fg} style={{ marginTop: 24 }} />} {isLoading && <ActivityIndicator color={theme.fg} style={{ marginTop: 24 }} />}
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>} {error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
@ -114,27 +95,6 @@ export function TagsScreen() {
); );
} }
function SegButton({
active,
onPress,
label,
}: {
active: boolean;
onPress: () => void;
label: string;
}) {
return (
<Pressable
onPress={onPress}
style={[styles.segButton, active && styles.segButtonActive]}
>
<Text style={[styles.segButtonText, active && styles.segButtonTextActive]}>
{label}
</Text>
</Pressable>
);
}
function TagChip({ tag, onPress }: { tag: TagCount; onPress: () => void }) { function TagChip({ tag, onPress }: { tag: TagCount; onPress: () => void }) {
return ( return (
<Pressable <Pressable
@ -154,56 +114,7 @@ function TagChip({ tag, onPress }: { tag: TagCount; onPress: () => void }) {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 16, paddingTop: 12 }, container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 16, paddingTop: 12 },
headerRow: { order: { flex: 0, width: 124 },
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingBottom: 4,
},
headerLabel: {
color: theme.muted,
fontSize: 12,
textTransform: 'uppercase',
letterSpacing: 1.2,
fontWeight: '700',
},
headerCount: { color: theme.fg, fontSize: 22, fontWeight: '800' },
hint: { color: theme.mutedDim, fontSize: 11, marginBottom: 12 },
toolbar: { flexDirection: 'row', gap: 12, marginBottom: 10 },
search: {
flex: 1,
backgroundColor: theme.card,
borderColor: theme.borderStrong,
borderWidth: 1.5,
borderRadius: 12,
color: theme.fg,
padding: 12,
fontSize: 16,
},
searchFocused: { borderColor: theme.borderFocus },
segment: {
flexDirection: 'row',
backgroundColor: theme.bgElevated,
borderColor: theme.border,
borderWidth: 1,
borderRadius: 12,
padding: 4,
marginBottom: 14,
alignSelf: 'flex-start',
},
segButton: { paddingHorizontal: 14, paddingVertical: 6, borderRadius: 8 },
segButtonActive: {
backgroundColor: theme.accent,
shadowColor: theme.accent,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.4,
shadowRadius: 6,
elevation: 2,
},
segButtonText: { color: theme.muted, fontWeight: '700', fontSize: 13 },
segButtonTextActive: { color: theme.fg },
gridRow: { gap: 10, marginBottom: 10 }, gridRow: { gap: 10, marginBottom: 10 },
chip: { chip: {

View file

@ -186,6 +186,66 @@ export function Toolbar({ children }: { children: React.ReactNode }) {
return <View style={styles.toolbar}>{children}</View>; 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({ const styles = StyleSheet.create({
toolbar: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 12 }, toolbar: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 12 },
headerIcon: { paddingHorizontal: 6, paddingVertical: 4 }, headerIcon: { paddingHorizontal: 6, paddingVertical: 4 },
@ -237,4 +297,30 @@ const styles = StyleSheet.create({
pillCompact: { height: 36, paddingHorizontal: 14 }, pillCompact: { height: 36, paddingHorizontal: 14 },
pillActive: { borderColor: theme.accentText, backgroundColor: `${theme.accent}1A` }, pillActive: { borderColor: theme.accentText, backgroundColor: `${theme.accent}1A` },
pillText: { fontFamily: fonts.medium, fontSize: 14 }, 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 },
}); });