feat(native): device-info api headers + DeviceLimitSheet UI + profile i18n sweep

- lib/api.ts: sends x-device-name + x-device-model + x-device-os headers
  (cached per session, URL-encoded). Backend persists into user_devices for
  visual differentiation in DeviceLimitSheet.
- DeviceLimitReachedSheet: renders name (primary) + model · OS-version
  (secondary), "Dieses Gerät"-Pill on isCurrent. Stale phantoms become
  distinguishable.
- Profile i18n sweep: 8 keys × 3 languages = 24 fixes — all {{var}} placeholders
  switched to %{var} matching i18next config (Vue-i18n leftover from Nuxt-port).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-15 21:16:22 +02:00
parent 5b1f89e749
commit 804d4a5861
6 changed files with 96 additions and 46 deletions

View File

@ -42,6 +42,16 @@ function DeviceLimitRow({
const { t } = useTranslation(); const { t } = useTranslation();
const colors = useColors(); const colors = useColors();
const primaryName = device.name ?? device.model ?? device.platform;
const secondaryParts: string[] = [];
if (device.model) secondaryParts.push(device.model);
if (device.osVersion) {
const osLabel = device.platform === 'android' ? 'Android' : 'iOS';
secondaryParts.push(`${osLabel} ${device.osVersion}`);
}
const secondaryLine = secondaryParts.join(' · ');
const showSecondary = secondaryLine.length > 0 && secondaryLine !== primaryName;
return ( return (
<View <View
style={{ style={{
@ -66,18 +76,40 @@ function DeviceLimitRow({
</View> </View>
<View style={{ flex: 1, minWidth: 0 }}> <View style={{ flex: 1, minWidth: 0 }}>
<Text <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
numberOfLines={1} <Text
style={{ numberOfLines={1}
fontSize: 15, style={{
color: colors.text, fontSize: 15,
fontFamily: 'Nunito_600SemiBold', color: colors.text,
flexShrink: 1, fontFamily: 'Nunito_600SemiBold',
}} flexShrink: 1,
> }}
{device.name ?? device.model ?? device.platform} >
</Text> {primaryName}
{device.model && device.name && !device.name.includes(device.model) ? ( </Text>
{device.isCurrent ? (
<View
style={{
backgroundColor: 'rgba(59,130,246,0.12)',
borderRadius: 999,
paddingHorizontal: 8,
paddingVertical: 2,
}}
>
<Text
style={{
fontSize: 10,
color: '#3b82f6',
fontFamily: 'Nunito_700Bold',
}}
>
{t('device_limit.this_device')}
</Text>
</View>
) : null}
</View>
{showSecondary ? (
<Text <Text
style={{ style={{
fontSize: 11, fontSize: 11,
@ -86,7 +118,7 @@ function DeviceLimitRow({
marginTop: 1, marginTop: 1,
}} }}
> >
{device.model} {secondaryLine}
</Text> </Text>
) : null} ) : null}
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 4 }}> <View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 4 }}>

View File

@ -1,6 +1,6 @@
import Constants from 'expo-constants'; import Constants from 'expo-constants';
import { supabase } from './supabase'; import { supabase } from './supabase';
import { getDeviceId, getPlatformName } from './deviceId'; import { getDeviceInfo } from './deviceId';
import { useDeviceLimitStore } from '../stores/deviceLimit'; import { useDeviceLimitStore } from '../stores/deviceLimit';
const apiUrl = Constants.expoConfig?.extra?.apiUrl as string; const apiUrl = Constants.expoConfig?.extra?.apiUrl as string;
@ -11,6 +11,22 @@ type FetchOptions = Omit<RequestInit, 'body'> & {
skipDeviceHeader?: boolean; skipDeviceHeader?: boolean;
}; };
let cachedDeviceHeaders: Record<string, string> | null = null;
async function getDeviceHeaders(): Promise<Record<string, string>> {
if (cachedDeviceHeaders) return cachedDeviceHeaders;
const info = await getDeviceInfo().catch(() => null);
if (!info) return {};
cachedDeviceHeaders = {
'x-device-id': info.deviceId,
'x-platform': info.platform,
'x-device-name': encodeURIComponent(info.name),
'x-device-model': encodeURIComponent(info.model),
'x-device-os': encodeURIComponent(info.osVersion),
};
return cachedDeviceHeaders;
}
/** /**
* Wrapper für Backend-API-Calls mit automatischem Auth-Token. * Wrapper für Backend-API-Calls mit automatischem Auth-Token.
* Pendant zum Nuxt-`useSafeFetch` aus apps/rebreak/. * Pendant zum Nuxt-`useSafeFetch` aus apps/rebreak/.
@ -35,11 +51,8 @@ export async function apiFetch<T = any>(
} }
if (!skipDeviceHeader) { if (!skipDeviceHeader) {
const deviceId = await getDeviceId().catch(() => null); const deviceHeaders = await getDeviceHeaders();
if (deviceId) { Object.assign(headers, deviceHeaders);
headers['x-device-id'] = deviceId;
headers['x-platform'] = getPlatformName();
}
} }
const res = await fetch(`${apiUrl}${path}`, { const res = await fetch(`${apiUrl}${path}`, {

View File

@ -589,7 +589,8 @@
"title": "Geräte-Limit erreicht", "title": "Geräte-Limit erreicht",
"subtitle": "%{count} von %{max} Geräten belegt (%{plan}) — entferne ein Gerät um weiterzumachen", "subtitle": "%{count} von %{max} Geräten belegt (%{plan}) — entferne ein Gerät um weiterzumachen",
"hint": "Entfernte Geräte können sich beim nächsten Login wieder registrieren.", "hint": "Entfernte Geräte können sich beim nächsten Login wieder registrieren.",
"remove_cta": "Gerät entfernen" "remove_cta": "Gerät entfernen",
"this_device": "Dieses Gerät"
}, },
"urge": { "urge": {
"title": "SOS — Atemübung", "title": "SOS — Atemübung",
@ -783,22 +784,22 @@
"crop_reset": "Zurücksetzen", "crop_reset": "Zurücksetzen",
"streak_section_label": "STREAK", "streak_section_label": "STREAK",
"streak_days_protected": "Tage geschützt", "streak_days_protected": "Tage geschützt",
"streak_since": "seit {{date}}", "streak_since": "seit %{date}",
"streak_longest": "Längste Streak: {{days}} Tage", "streak_longest": "Längste Streak: %{days} Tage",
"cooldown": { "cooldown": {
"heading": "COOLDOWN-VERLAUF", "heading": "COOLDOWN-VERLAUF",
"window_label": "letzte {{weeks}}W", "window_label": "letzte %{weeks}W",
"week_label": "W{{n}}", "week_label": "W%{n}",
"none": "Keine Cooldowns in den letzten 8 Wochen", "none": "Keine Cooldowns in den letzten 8 Wochen",
"count_one": "1 Cooldown in {{weeks}} Wochen", "count_one": "1 Cooldown in %{weeks} Wochen",
"count_other": "{{n}} Cooldowns in {{weeks}} Wochen", "count_other": "%{n} Cooldowns in %{weeks} Wochen",
"avg_last": "Ø 1 pro {{avg}} Wochen · zuletzt {{date}}", "avg_last": "Ø 1 pro %{avg} Wochen · zuletzt %{date}",
"patterns": { "patterns": {
"toggle_label": "Mehr Infos", "toggle_label": "Mehr Infos",
"hour_heading": "Wann startest du Cooldowns?", "hour_heading": "Wann startest du Cooldowns?",
"day_heading": "An welchen Tagen?", "day_heading": "An welchen Tagen?",
"reason_heading": "Häufige Begriffe", "reason_heading": "Häufige Begriffe",
"cancel_rate": "Cooldowns abgebrochen: {{pct}}%", "cancel_rate": "Cooldowns abgebrochen: %{pct}%",
"not_enough": "Noch keine Muster erkannt", "not_enough": "Noch keine Muster erkannt",
"weekday_mon": "Mo", "weekday_mon": "Mo",
"weekday_tue": "Di", "weekday_tue": "Di",

View File

@ -589,7 +589,8 @@
"title": "Device limit reached", "title": "Device limit reached",
"subtitle": "%{count} of %{max} device slots used (%{plan}) — remove a device to continue", "subtitle": "%{count} of %{max} device slots used (%{plan}) — remove a device to continue",
"hint": "Removed devices can re-register on next sign-in.", "hint": "Removed devices can re-register on next sign-in.",
"remove_cta": "Remove device" "remove_cta": "Remove device",
"this_device": "This device"
}, },
"urge": { "urge": {
"title": "SOS — Breathing exercise", "title": "SOS — Breathing exercise",
@ -783,22 +784,22 @@
"crop_reset": "Reset", "crop_reset": "Reset",
"streak_section_label": "STREAK", "streak_section_label": "STREAK",
"streak_days_protected": "days protected", "streak_days_protected": "days protected",
"streak_since": "since {{date}}", "streak_since": "since %{date}",
"streak_longest": "Longest streak: {{days}} days", "streak_longest": "Longest streak: %{days} days",
"cooldown": { "cooldown": {
"heading": "COOLDOWN HISTORY", "heading": "COOLDOWN HISTORY",
"window_label": "last {{weeks}}W", "window_label": "last %{weeks}W",
"week_label": "W{{n}}", "week_label": "W%{n}",
"none": "No cooldowns in the last 8 weeks", "none": "No cooldowns in the last 8 weeks",
"count_one": "1 cooldown over {{weeks}} weeks", "count_one": "1 cooldown over %{weeks} weeks",
"count_other": "{{n}} cooldowns over {{weeks}} weeks", "count_other": "%{n} cooldowns over %{weeks} weeks",
"avg_last": "Ø 1 every {{avg}} weeks · last {{date}}", "avg_last": "Ø 1 every %{avg} weeks · last %{date}",
"patterns": { "patterns": {
"toggle_label": "More insights", "toggle_label": "More insights",
"hour_heading": "When do you start cooldowns?", "hour_heading": "When do you start cooldowns?",
"day_heading": "Which days?", "day_heading": "Which days?",
"reason_heading": "Common terms", "reason_heading": "Common terms",
"cancel_rate": "Cooldowns cancelled: {{pct}}%", "cancel_rate": "Cooldowns cancelled: %{pct}%",
"not_enough": "Not enough patterns yet", "not_enough": "Not enough patterns yet",
"weekday_mon": "Mon", "weekday_mon": "Mon",
"weekday_tue": "Tue", "weekday_tue": "Tue",

View File

@ -589,7 +589,8 @@
"title": "Limite d'appareils atteinte", "title": "Limite d'appareils atteinte",
"subtitle": "%{count} sur %{max} emplacements utilisés (%{plan}) — supprimez un appareil pour continuer", "subtitle": "%{count} sur %{max} emplacements utilisés (%{plan}) — supprimez un appareil pour continuer",
"hint": "Les appareils supprimés peuvent se réenregistrer à la prochaine connexion.", "hint": "Les appareils supprimés peuvent se réenregistrer à la prochaine connexion.",
"remove_cta": "Supprimer un appareil" "remove_cta": "Supprimer un appareil",
"this_device": "Cet appareil"
}, },
"urge": { "urge": {
"title": "SOS — Exercice de respiration", "title": "SOS — Exercice de respiration",
@ -783,22 +784,22 @@
"crop_reset": "Réinitialiser", "crop_reset": "Réinitialiser",
"streak_section_label": "SÉRIE", "streak_section_label": "SÉRIE",
"streak_days_protected": "jours protégés", "streak_days_protected": "jours protégés",
"streak_since": "depuis {{date}}", "streak_since": "depuis %{date}",
"streak_longest": "Série la plus longue : {{days}} jours", "streak_longest": "Série la plus longue : %{days} jours",
"cooldown": { "cooldown": {
"heading": "HISTORIQUE DES PAUSES", "heading": "HISTORIQUE DES PAUSES",
"window_label": "{{weeks}} dernières sem.", "window_label": "%{weeks} dernières sem.",
"week_label": "S{{n}}", "week_label": "S%{n}",
"none": "Aucune pause dans les 8 dernières semaines", "none": "Aucune pause dans les 8 dernières semaines",
"count_one": "1 pause sur {{weeks}} semaines", "count_one": "1 pause sur %{weeks} semaines",
"count_other": "{{n}} pauses sur {{weeks}} semaines", "count_other": "%{n} pauses sur %{weeks} semaines",
"avg_last": "Ø 1 toutes les {{avg}} semaines · dernière {{date}}", "avg_last": "Ø 1 toutes les %{avg} semaines · dernière %{date}",
"patterns": { "patterns": {
"toggle_label": "Plus d'infos", "toggle_label": "Plus d'infos",
"hour_heading": "Quand démarrez-vous des pauses ?", "hour_heading": "Quand démarrez-vous des pauses ?",
"day_heading": "Quels jours ?", "day_heading": "Quels jours ?",
"reason_heading": "Termes fréquents", "reason_heading": "Termes fréquents",
"cancel_rate": "Pauses annulées : {{pct}}%", "cancel_rate": "Pauses annulées : %{pct}%",
"not_enough": "Pas encore assez de données", "not_enough": "Pas encore assez de données",
"weekday_mon": "Lun", "weekday_mon": "Lun",
"weekday_tue": "Mar", "weekday_tue": "Mar",

View File

@ -6,8 +6,10 @@ export type DeviceLimitDevice = {
platform: string; platform: string;
model: string | null; model: string | null;
name: string | null; name: string | null;
osVersion: string | null;
lastSeenAt: string; lastSeenAt: string;
createdAt: string; createdAt: string;
isCurrent?: boolean;
}; };
type DeviceLimitState = { type DeviceLimitState = {