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:
parent
5b1f89e749
commit
804d4a5861
@ -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 }}>
|
||||||
|
|||||||
@ -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}`, {
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user