Pomiar WCAG na realnych parach z apki (2026-08-13):
accent #B23A48 jako tekst: 3,21 na bg | 2,96 na bgElevated | 2,75 na card
border #3A3128 jako granica: 1,48 na bg | 1,27 na card
Czyli kolor MARKI byl ponizej nawet progu 3:1 dla elementow interfejsu,
a nie tylko ponizej 4,5 dla tekstu. Dotyczylo to aktywnych zakladek,
gwiazdek ulubionych, linkow i chipow. Ramki przy 1,27 nie byly granica,
tylko cieniem.
NIE zmieniam koloru marki. Podniesienie samego `accent` do progu tekstu
wymagaloby zejscia w roz (#CE6975), co likwiduje tozsamosc, ktorej broni
naglowek theme.ts. Zamiast tego rozdzielam ROLE tego samego koloru:
accent zostaje do WYPELNIEN — jasny tekst na nim ma 5,03, przechodzi
accentText #C75260 dla tekstu i obwodek — 4,30 / 3,96 / 3,68
62 podmiany w 24 plikach (34 color, 26 borderColor, 2 thumbColor).
38 wypelnien nietknietych. Sprawdzone skryptem, ze zaden zmieniony tekst
nie stoi na tle akcentu.
borderStrong #7B7065 dla granic NIOSACYCH ZNACZENIE (pola tekstowe, 12
miejsc); dekoracyjne wloski zostaja na `border`. Wartosc dobrana pod obie
powierzchnie, bo pola leza i na bg, i na card: #6B6157 dawal na karcie
2,66, a #73695E dokladnie 2,999, czyli wciaz ponizej progu.
218 lines
6.4 KiB
TypeScript
218 lines
6.4 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
|
import {
|
|
ActivityIndicator,
|
|
Animated,
|
|
Pressable,
|
|
StyleSheet,
|
|
Text,
|
|
View,
|
|
Vibration,
|
|
} from 'react-native';
|
|
import * as Haptics from 'expo-haptics';
|
|
import {
|
|
authenticateBiometric,
|
|
biometricAvailable,
|
|
getSettings,
|
|
verifyPin,
|
|
} from '../lib/applock';
|
|
import { theme } from '../theme';
|
|
|
|
interface Props {
|
|
onUnlock: () => void;
|
|
onLogout?: () => void;
|
|
}
|
|
|
|
const KEYS: (string | null)[] = ['1', '2', '3', '4', '5', '6', '7', '8', '9', null, '0', '⌫'];
|
|
|
|
export function AppLockScreen({ onUnlock, onLogout }: Props) {
|
|
const [pin, setPin] = useState('');
|
|
const [bioAvailable, setBioAvailable] = useState(false);
|
|
const [bioEnabled, setBioEnabled] = useState(false);
|
|
const [verifying, setVerifying] = useState(false);
|
|
const [error, setError] = useState(false);
|
|
const shake = useRef(new Animated.Value(0)).current;
|
|
|
|
useEffect(() => {
|
|
(async () => {
|
|
const [settings, available] = await Promise.all([getSettings(), biometricAvailable()]);
|
|
setBioEnabled(settings.bioEnabled);
|
|
setBioAvailable(available);
|
|
if (settings.bioEnabled && available) {
|
|
try {
|
|
const ok = await authenticateBiometric();
|
|
if (ok) onUnlock();
|
|
} catch {
|
|
// fall through to pin entry
|
|
}
|
|
}
|
|
})();
|
|
}, [onUnlock]);
|
|
|
|
async function tryUnlock(candidate: string) {
|
|
setVerifying(true);
|
|
const ok = await verifyPin(candidate);
|
|
setVerifying(false);
|
|
if (ok) {
|
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
|
|
onUnlock();
|
|
} else {
|
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {});
|
|
setError(true);
|
|
setPin('');
|
|
Vibration.vibrate(50);
|
|
Animated.sequence([
|
|
Animated.timing(shake, { toValue: 10, duration: 50, useNativeDriver: true }),
|
|
Animated.timing(shake, { toValue: -10, duration: 50, useNativeDriver: true }),
|
|
Animated.timing(shake, { toValue: 10, duration: 50, useNativeDriver: true }),
|
|
Animated.timing(shake, { toValue: 0, duration: 50, useNativeDriver: true }),
|
|
]).start();
|
|
}
|
|
}
|
|
|
|
function press(key: string) {
|
|
if (verifying) return;
|
|
setError(false);
|
|
if (key === '⌫') {
|
|
setPin((p) => p.slice(0, -1));
|
|
Haptics.selectionAsync().catch(() => {});
|
|
return;
|
|
}
|
|
if (pin.length >= 8) return;
|
|
const next = pin + key;
|
|
setPin(next);
|
|
Haptics.selectionAsync().catch(() => {});
|
|
if (next.length >= 4) {
|
|
// Auto-verify when at least 4 digits, nicer UX. User can keep typing if PIN longer.
|
|
tryUnlock(next);
|
|
}
|
|
}
|
|
|
|
async function biometricRetry() {
|
|
if (!bioEnabled || !bioAvailable) return;
|
|
const ok = await authenticateBiometric();
|
|
if (ok) onUnlock();
|
|
}
|
|
|
|
return (
|
|
<View style={styles.root}>
|
|
<View style={styles.header}>
|
|
<View style={styles.lockBadge}>
|
|
<Text style={styles.lockGlyph}>g</Text>
|
|
</View>
|
|
<Text style={styles.title}>Goon locked</Text>
|
|
<Text style={styles.subtitle}>
|
|
{bioEnabled && bioAvailable ? 'Use fingerprint or PIN' : 'Enter PIN'}
|
|
</Text>
|
|
</View>
|
|
|
|
<Animated.View style={[styles.dots, { transform: [{ translateX: shake }] }]}>
|
|
{[0, 1, 2, 3].map((i) => {
|
|
const filled = i < pin.length;
|
|
return (
|
|
<View
|
|
key={i}
|
|
style={[
|
|
styles.dot,
|
|
filled && styles.dotFilled,
|
|
error && styles.dotError,
|
|
]}
|
|
/>
|
|
);
|
|
})}
|
|
</Animated.View>
|
|
|
|
{verifying ? <ActivityIndicator color={theme.muted} style={{ marginTop: 12 }} /> : <View style={{ height: 28 }} />}
|
|
|
|
<View style={styles.pad}>
|
|
{KEYS.map((k, i) => {
|
|
if (k === null) {
|
|
return bioEnabled && bioAvailable ? (
|
|
<Pressable key={i} style={styles.key} onPress={biometricRetry}>
|
|
<Text style={styles.keyBio}>👆</Text>
|
|
</Pressable>
|
|
) : (
|
|
<View key={i} style={[styles.key, styles.keyEmpty]} />
|
|
);
|
|
}
|
|
return (
|
|
<Pressable
|
|
key={i}
|
|
style={({ pressed }) => [styles.key, pressed && styles.keyPressed]}
|
|
onPress={() => press(k)}
|
|
>
|
|
<Text style={k === '⌫' ? styles.keyBack : styles.keyText}>{k}</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</View>
|
|
|
|
{onLogout ? (
|
|
<Pressable onPress={onLogout} hitSlop={12} style={styles.logout}>
|
|
<Text style={styles.logoutText}>Log out</Text>
|
|
</Pressable>
|
|
) : null}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const KEY_SIZE = 76;
|
|
|
|
const styles = StyleSheet.create({
|
|
root: {
|
|
flex: 1,
|
|
backgroundColor: theme.bg,
|
|
paddingTop: 80,
|
|
alignItems: 'center',
|
|
paddingHorizontal: 24,
|
|
},
|
|
header: { alignItems: 'center', marginBottom: 36 },
|
|
lockBadge: {
|
|
width: 72,
|
|
height: 72,
|
|
borderRadius: 18,
|
|
backgroundColor: theme.card,
|
|
borderWidth: 1,
|
|
borderColor: theme.borderFocus,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginBottom: 16,
|
|
},
|
|
lockGlyph: { color: theme.accentGlow, fontSize: 38, fontWeight: '900', lineHeight: 42 },
|
|
title: { color: theme.fg, fontSize: 22, fontWeight: '700' },
|
|
subtitle: { color: theme.muted, fontSize: 13, marginTop: 4 },
|
|
dots: { flexDirection: 'row', gap: 16, marginTop: 8 },
|
|
dot: {
|
|
width: 14,
|
|
height: 14,
|
|
borderRadius: 7,
|
|
borderWidth: 1.5,
|
|
borderColor: theme.border,
|
|
},
|
|
dotFilled: { backgroundColor: theme.accent, borderColor: theme.accentText },
|
|
dotError: { borderColor: theme.bad, backgroundColor: theme.bad },
|
|
pad: {
|
|
marginTop: 20,
|
|
flexDirection: 'row',
|
|
flexWrap: 'wrap',
|
|
width: KEY_SIZE * 3 + 32,
|
|
justifyContent: 'space-between',
|
|
rowGap: 14,
|
|
},
|
|
key: {
|
|
width: KEY_SIZE,
|
|
height: KEY_SIZE,
|
|
borderRadius: KEY_SIZE / 2,
|
|
backgroundColor: theme.card,
|
|
borderWidth: 1,
|
|
borderColor: theme.border,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
keyEmpty: { backgroundColor: 'transparent', borderColor: 'transparent' },
|
|
keyPressed: { backgroundColor: theme.bgElevated, borderColor: theme.borderFocus },
|
|
keyText: { color: theme.fg, fontSize: 28, fontWeight: '500' },
|
|
keyBack: { color: theme.muted, fontSize: 22 },
|
|
keyBio: { fontSize: 28 },
|
|
logout: { marginTop: 28, padding: 12 },
|
|
logoutText: { color: theme.muted, fontSize: 13 },
|
|
});
|