From cd6e73fbb8025ef5ec84fbefafd4d63ddf3126d0 Mon Sep 17 00:00:00 2001 From: Michael Gasperini Date: Fri, 23 Oct 2026 17:14:05 +0200 Subject: [PATCH] release: Keysoft 3.3.2 - Restore native Argon2id in production: Expo Go detection now relies on executionEnvironment/appOwnership instead of expoGoConfig, so PIN unlock no longer forces the pure-JS PBKDF2 fallback; existing PBKDF2 vaults upgrade on the next PIN login. - Show a blocking progress dialog while backup export/import derives the key, writes or reads the file and imports the payload. - Migrate the mobile UI to the Nocturne v2 design system (responsive window classes, type/spacing/color tokens, responsive dialogs/sheets, 44/48pt touch targets, readable widths, contrast fixes) and address the browser UI review findings. - Prepare release 3.3.2 (versionCode 131), docs, release notes, CHANGELOG and third-party notices; align expo-notifications to 57.0.24; keep Jest/Expo caches inside the project tmp directory. - Verified with bun install --frozen-lockfile, bun run verify (36 suites / 253 tests, Expo Doctor 21/21), bun audit --audit-level=critical and an Android export. --- .easignore | 2 + CHANGELOG.md | 63 +++ CITATION.cff | 4 +- THIRD_PARTY_NOTICES.md | 2 +- app.config.js | 4 +- bun.lock | 4 +- docs/architecture.md | 15 +- docs/design-system.md | 189 +++++++++ docs/development.md | 10 +- docs/pre-build-security-ui-review.md | 36 +- docs/release.md | 13 +- docs/releases/3.3.2.md | 67 +++ jest.config.js | 3 + package.json | 16 +- src/__tests__/architecture/Structure.test.ts | 11 + .../design-system/DesignSystem.test.ts | 114 ++++++ .../design-system/ResponsiveLayout.test.ts | 69 ++++ .../services/CryptoServiceEnvironment.test.ts | 42 ++ src/__tests__/utils/env.test.ts | 49 +++ src/components/ErrorBoundary.tsx | 44 +- src/components/NotificationBell.tsx | 139 +++++-- src/components/PasswordCard.tsx | 60 ++- src/components/ScreenWrapper.tsx | 57 ++- src/components/ui/bottom-sheet.tsx | 130 +++--- src/components/ui/dialog.tsx | 140 ++++--- src/components/ui/list-item.tsx | 63 +-- src/components/ui/pin-keypad.tsx | 44 +- src/components/ui/switch.tsx | 12 +- src/components/ui/toast.tsx | 43 +- src/constants/designSystem.ts | 29 ++ src/constants/layout.ts | 75 ++++ src/constants/theme.ts | 254 +++++++----- src/hooks/settings/useExportImport.ts | 31 +- src/locales/en.ts | 1 + src/locales/it.ts | 1 + src/navigation/TabNavigator.tsx | 36 +- src/navigation/index.tsx | 24 +- src/screens/AuthScreen.tsx | 74 ++-- src/screens/HomeScreen.tsx | 197 +++++---- src/screens/NoteDetailScreen.tsx | 156 ++++--- src/screens/NotesScreen.tsx | 150 +++++-- src/screens/OnboardingScreen.tsx | 181 +++++--- src/screens/OpenSourceScreen.tsx | 35 +- src/screens/PasswordDetailScreen.tsx | 387 ++++++++++-------- src/screens/PasswordGeneratorScreen.tsx | 169 +++++--- src/screens/PrivacyPolicyScreen.tsx | 65 ++- src/screens/SettingsScreen.tsx | 311 +++++++------- src/screens/VaultHealthScreen.tsx | 111 ++--- src/services/auth/authService.ts | 4 +- src/services/crypto/cryptoService.ts | 16 +- src/utils/env.ts | 21 +- src/utils/responsive.ts | 220 +++++----- 52 files changed, 2652 insertions(+), 1341 deletions(-) create mode 100644 docs/design-system.md create mode 100644 docs/releases/3.3.2.md create mode 100644 src/__tests__/design-system/DesignSystem.test.ts create mode 100644 src/__tests__/design-system/ResponsiveLayout.test.ts create mode 100644 src/__tests__/services/CryptoServiceEnvironment.test.ts create mode 100644 src/__tests__/utils/env.test.ts create mode 100644 src/constants/layout.ts diff --git a/.easignore b/.easignore index ddc0881..bded095 100644 --- a/.easignore +++ b/.easignore @@ -105,6 +105,8 @@ dist/ web-build/ .expo/ .expo-shared/ +tmp/ +coverage/ # File di documentazione docs/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 92fdb23..8574d88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable project changes are documented here. ## [Unreleased] +## [3.3.2] - 2026-10-23 + +### Release + +- Prepared Keysoft 3.3.2 with native runtime 3.3.2 and Android production build target 131. + EAS remote versioning remains authoritative: the production profile auto-increments the + remote Android version from 130 to 131. Google Play submission remains a separate manual + step. +- Aligns `expo-notifications` with the Expo SDK 57 patch set (`57.0.24`) so Expo Doctor + passes, and uses the portable `bun x` invoker in release scripts instead of a `bunx` + shim. Jest keeps its cache and Expo CLI temp files inside the project `tmp/` directory. + ### Security - Made transparent KDF upgrades and master-password changes crash-safe. The vault is @@ -21,6 +33,57 @@ All notable project changes are documented here. always rebuilds from the committed cache and re-encryptions run before any concurrent write on the same collection. +### Fixed + +- Production builds no longer misdetect themselves as Expo Go. `Constants.expoGoConfig` + is truthy in standalone builds because expo-constants resolves it to the embedded update + manifest, so the app forced the pure-JS PBKDF2 fallback (about 6–7 seconds per PIN + unlock on a current phone), skipped the native Argon2id KDF, and kept local notifications + in history-only mode. Environment detection now relies on `executionEnvironment` and + `appOwnership`. New vaults use Argon2id again, and existing PBKDF2 vaults are + transparently upgraded on the next PIN login (the first unlock after the update still + derives the old PBKDF2 key). +- Backup export and import now show a blocking progress dialog while the password-derived + key is calculated, the file is written or read, and the decrypted payload is imported. + The screen no longer appears frozen for several seconds with no feedback. + +### UI/UX + +- Reworked the mobile design system ("Nocturne v2") around a single source of truth: + responsive window classes (compact/medium/expanded/large) in `constants/layout.ts`, a + 4pt spacing grid, an expanded type scale with `DesignSystem.text()`, modern radii and + elevation tokens, semantic contrast-checked status colors, an `onPrimary` token and + refreshed light/dark surfaces. Documented in `docs/design-system.md`. +- `ScreenWrapper` now owns the horizontal gutter and centered content width for every + screen, so layouts stay consistent from compact phones to tablets and split view. + Home, vault, notes, settings, generator, health, legal and onboarding screens were + migrated off ad-hoc `Math.min(width - padding, N)` widths and literal sizes. +- Responsive behavior improved for all window classes: two- and three-column vault grids, + short-landscape spacing, split-view/foldable widths, tablet dialog/bottom-sheet widths + (420–680), adaptive PIN keypad sizing, and a 44/48pt touch-target baseline. Home/search/ + notification/dialog/sheet controls, list rows and password cards now use the shared + tokens; the notification header action and sheet close controls reach 48pt. +- Typography and contrast fixes: readable minimum sizes for labels and metadata, WCAG AA + contrast for secondary/tertiary text and status colors in both themes, and + `onPrimary` text on primary-filled buttons (the previous white-on-light-blue pair + failed contrast in dark mode). +- Added design-system regression tests for token scales, contrast ratios, window-class + resolution and legacy helper compatibility. +- UI review follow-up from browser testing at compact, split-view and tablet widths: + dialog headers now stack the icon above a wrapped, centered title/description with a + 48pt close pinned to the corner (all alerts, confirmations and export/import loaders); + Settings row separators use the `divider` token instead of React Native's default black + border (same audit applied to the other screens); the decorative vault-health progress + bar was removed because it showed a fixed percentage with no underlying analysis; vault + cards now fill their 2/3-column grid slot; text/form screens cap at the 720pt readable + width on tablets; the unlock screen groups identity and keypad with even spacing; and + the generator uses the shared switch wrapper so the on-state thumb matches the theme on + web too. +- Development-session fixes: the web target no longer attempts to load the native KDF + (no misleading Argon2 console error), the toast uses `style.pointerEvents` instead of the + deprecated prop, and Jest keeps its cache under `tmp/jest-cache` so tests still run + while Metro is open and in shells without user-temp write access. + ## [3.3.1] - 2026-08-09 ### Release diff --git a/CITATION.cff b/CITATION.cff index aeb9993..fb19ade 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -5,6 +5,6 @@ authors: - family-names: Gasperini given-names: Michael url: 'https://github.com/TheStreamCode/keysoft' -version: '3.3.1' -date-released: "2026-08-09" +version: '3.3.2' +date-released: "2026-10-23" license: 'Apache-2.0' diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 34045ce..e6e3e7e 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,6 +1,6 @@ # Third-Party Notices -Keysoft includes open-source dependencies. The inventory below records the direct runtime dependencies resolved for Keysoft 3.3.1. Transitive packages and their license files remain available in the package distribution and lockfile. +Keysoft includes open-source dependencies. The inventory below records the direct runtime dependencies resolved for Keysoft 3.3.2. Transitive packages and their license files remain available in the package distribution and lockfile. Unless noted otherwise, these direct dependencies are licensed under the MIT License: diff --git a/app.config.js b/app.config.js index 64b94a6..76687c8 100644 --- a/app.config.js +++ b/app.config.js @@ -1,7 +1,7 @@ module.exports = { name: 'Keysoft', slug: 'keysoft', - version: '3.3.1', + version: '3.3.2', scheme: 'keysoft', orientation: 'default', // Supports both portrait and landscape userInterfaceStyle: 'automatic', @@ -55,7 +55,7 @@ module.exports = { backgroundColor: '#FFFFFF', }, package: 'it.mikesoft.keysoft', - versionCode: 130, + versionCode: 131, permissions: [ 'INTERNET', 'USE_BIOMETRIC', diff --git a/bun.lock b/bun.lock index f35f2eb..2e700ab 100644 --- a/bun.lock +++ b/bun.lock @@ -27,7 +27,7 @@ "expo-local-authentication": "~57.0.3", "expo-localization": "~57.0.2", "expo-navigation-bar": "~57.0.3", - "expo-notifications": "~57.0.23", + "expo-notifications": "~57.0.24", "expo-screen-capture": "~57.0.4", "expo-secure-store": "~57.0.4", "expo-sharing": "~57.0.24", @@ -933,7 +933,7 @@ "expo-navigation-bar": ["expo-navigation-bar@57.0.3", "", { "dependencies": { "debug": "^4.3.2" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-+9Q/2OQtpYjObmby1EMpFyhfK5LyT/oHD3lfkU6Z9as8f7arq7vGTlAXzYDP+xK2t76g9TFk//zya56vN5CkzA=="], - "expo-notifications": ["expo-notifications@57.0.23", "", { "dependencies": { "@expo/image-utils": "^0.11.6", "abort-controller": "^3.0.0", "badgin": "^1.1.5", "expo-application": "~57.0.3", "expo-constants": "~57.0.22" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-KdHROY5tvbzlDJImJKF91F8GRpV4hP+RLPC7NJgiFNyzb0clETpV0hJNdbFrfyubZf+cqx88+WuwFsH9Yl+kzg=="], + "expo-notifications": ["expo-notifications@57.0.24", "", { "dependencies": { "@expo/image-utils": "^0.11.6", "abort-controller": "^3.0.0", "badgin": "^1.1.5", "expo-application": "~57.0.3", "expo-constants": "~57.0.22" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-Hi5PBQlcrDDQ3MTqkH28NkUfeTvr80cXv1xUlvYaxy4AOmgkd3jDN1s7fWzaO5CYyb42BC5mPlIin6C3DJJzxA=="], "expo-screen-capture": ["expo-screen-capture@57.0.4", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-08+bHhNnEKABCNB687LMYQPhVL200exgCqNdHnEkAo0UyS+SN8k+lh9w9bJ+tAmYpGa76o+hRYMqwpv7oBPEOA=="], diff --git a/docs/architecture.md b/docs/architecture.md index ef56a37..9ee1a84 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -19,7 +19,9 @@ Keysoft is an offline-first React Native application built with Expo. The archit ```text src/ - components/ Shared UI and reusable controls + components/ Shared UI, screens primitives, and app chrome + ui/ Design-system primitives (dialog, sheet, toast, list, keypad) + constants/ Design tokens, layout metrics, theme, domain constants contexts/ Auth, language, theme, and alert state hooks/ Complex screen behavior and orchestration settings/ Settings workflows split out of the screen component @@ -37,7 +39,16 @@ src/ Screens own presentation state and user interactions. They should not implement cryptography, persistence rules, or business validation directly. Complex screen workflows should move into hooks or services. -The presentation layer follows the Nocturne system: semantic light/dark theme tokens, compact outlined controls, bounded responsive content, a flat five-slot tab bar, and copy-first read views separated from edit forms. Shared dialog, bottom-sheet, toast, motion, and PIN-keypad primitives live under `src/components/ui`; motion honors the operating-system reduced-motion preference and is limited to opacity and transform effects. +The presentation layer follows the Nocturne design system, documented in +[design-system.md](design-system.md): semantic light/dark color tokens, a 4pt spacing +grid, a shared type scale, compact outlined controls, bounded responsive content, +a flat five-slot tab bar, and copy-first read views separated from edit forms. +`src/constants/layout.ts` defines the compact/medium/expanded/large window classes +and `useResponsiveLayout` resolves columns, gutters, content widths and landscape +states from them. `ScreenWrapper` owns the horizontal gutter and centered content +width. Shared dialog, bottom-sheet, toast, motion, and PIN-keypad primitives live +under `src/components/ui`; motion honors the operating-system reduced-motion +preference and is limited to opacity and transform effects. `ProfileAvatar` owns photo rendering and the initial-based fallback across unlock, vault, and settings surfaces. `KeysoftMark` renders the canonical shield-and-eye asset in onboarding, matching the launcher, adaptive, splash, and web artwork configured in `app.config.js`. diff --git a/docs/design-system.md b/docs/design-system.md new file mode 100644 index 0000000..ced75b4 --- /dev/null +++ b/docs/design-system.md @@ -0,0 +1,189 @@ +# Design System + +Keysoft uses a mobile-first design system ("Nocturne v2") for the Android app. It +defines the visual language, the type and spacing scales, the responsive window +classes and the rules that keep screens consistent across phones, foldables, +split view, tablets and the development web target. + +The tokens are the contract. Screens should consume semantic tokens and shared +primitives instead of hard-coded sizes, colors or per-screen breakpoints. + +## Files + +| File | Responsibility | +| ---------------------------------- | --------------------------------------------------------------------- | +| `src/constants/layout.ts` | Window size classes, gutters, content widths, touch targets. | +| `src/constants/theme.ts` | Semantic light/dark themes, type scale, spacing, radii, elevation. | +| `src/constants/designSystem.ts` | Consumer-facing token bundle and the `DesignSystem.text()` helper. | +| `src/utils/responsive.ts` | `resolveLayout()` and `useResponsiveLayout()` for responsive screens. | +| `src/components/ScreenWrapper.tsx` | Owns the horizontal gutter, centered content width and lateral insets. | +| `src/components/ui/*` | Shared Dialog, BottomSheet, Toast, ListItem, keypad and motion rules. | + +## Principles + +1. **Content first.** Calm neutral surfaces, one blue accent and generous + spacing; no decorative layers that compete with credentials or PIN entry. +2. **Mobile-first, adaptive.** Layouts scale by window size class, not by + device brand. Touch, typography and spacing stay comfortable on compact + phones and do not stretch into unreadable lines on tablets. +3. **Semantic tokens.** A screen asks for `textSecondary` or `error`, never for + a literal hex value. Both themes keep the same token names. +4. **Accessible by default.** Interactive targets are at least 44pt (48pt for + primary controls), text pairs meet WCAG AA contrast and motion honors the + reduced-motion preference. +5. **One primitive per job.** Dialogs, bottom sheets and toasts come from + `src/components/ui`; screens do not build parallel modal implementations. + +## Colors + +`theme.colors` exposes semantic roles in both light and dark mode. The palette +is validated by unit tests (`src/__tests__/design-system`): + +- Text: `text`, `textSecondary`, `textTertiary` (all ≥ 4.5:1 on the app + background), `textDisabled` and `textLight`/`textDark` for fixed surfaces. +- Primary: `primary`, `primaryLight`, `primaryDark`, `onPrimary`, + `primaryContainer`, `onPrimaryContainer`. Primary-filled controls use + `onPrimary`, never plain white: in dark mode the accent is light and white + text would fail contrast. +- Surfaces: `background`, `backgroundElevated`, `backgroundMuted`, + `backgroundSunken`, `surfaceMuted`, `card`, `elevated` plus + `theme.surfaces.level0..level3` and `surfaces.overlay`. +- Status: `error`, `success`, `warning`, `info` are mode-specific and pass + normal-text contrast; `errorSoft`/`successSoft`/`warningSoft`/`infoSoft` are + the matching low-emphasis backgrounds. +- Lines and controls: `border`, `borderStrong`, `divider`, `inputBackground`, + `inputBorder`, `inputText`, `chip*`, `switch*`, `focusRing`. + +Do not add literal colors in screens. Domain palettes (category colors, note +colors, password-strength colors) are data, not UI chrome, and stay in their +constants modules. + +## Typography + +`DesignSystem.text(variant, color?)` returns a valid React Native text style. +It is the only supported way to consume the type scale; the raw token stores +`size`/`weight` and must not be spread into styles. + +| Variant | Size / line height | Weight | Typical use | +| ------------ | ------------------ | ------ | ------------------------------------ | +| `display` | 32 / 40 | 700 | Empty states and hero numbers. | +| `headline` | 26 / 32 | 600 | Auth welcome, large section titles. | +| `titleLarge` | 22 / 28 | 600 | Screen headers. | +| `title` | 20 / 28 | 600 | Dialog and sheet titles. | +| `subtitle` | 18 / 24 | 600 | Card headings, empty-state title. | +| `body` | 16 / 24 | 400 | Long-form body text. | +| `bodySmall` | 14 / 20 | 400 | Dense lists and secondary content. | +| `label` | 14 / 20 | 500 | Form labels, list titles. | +| `labelSmall` | 12 / 16 | 500 | Chips and compact metadata labels. | +| `caption` | 12 / 16 | 400 | Helper text, timestamps. | +| `micro` | 11 / 14 | 500 | Uppercase overlines and legal meta. | +| `button` | 15 / 20 | 600 | Primary/secondary button labels. | + +`DesignSystem.fonts.secure` stays available for secret/password rendering and +`DesignSystem.fonts.main` is the system UI family. + +## Spacing, Radii, Elevation, Motion + +- **Spacing** is a 4pt grid: `xxs 2`, `xs 4`, `s 8`, `m 12`, `l 16`, `xl 24`, + `xxl 32`, `xxxl 48`, `huge 64`. Use `none` for explicit zero. +- **Radii**: `xs 6`, `small 8`, `medium 12`, `large 16`, `xl 20`, `xxl 28`, + `pill 999`. Cards use `large`, sheets use `xxl` on the top edge, controls use + `medium`. +- **Elevation**: `theme.shadows.small|medium|large` (plus the `elevation` + alias) for cards, sheets and dialogs. Prefer borders and tonal surfaces over + heavy shadows. +- **Motion**: `DesignSystem.motion.durations` (`fastest 80` … `long 320`) and + `DesignSystem.motion.easing`. All entering/exiting motion must be skipped or + reduced when `usePrefersReducedMotion()` is true. + +## Layout And Responsiveness + +`src/constants/layout.ts` defines the window size classes shared by the theme +and the responsive hook: + +| Class | Width (dp) | Columns | Gutter | Content max width | Header | +| ---------- | ---------- | ------- | ------ | ----------------- | ------ | +| `compact` | 0–599 | 1 | 16 | ∞ | 56 | +| `medium` | 600–839 | 2 | 24 | 720 | 64 | +| `expanded` | 840–1199 | 2 | 32 | 960 | 64 | +| `large` | 1200+ | 3 | 32 | 1120 | 72 | + +Rules: + +- `ScreenWrapper` owns the horizontal gutter, the centered content width and the + lateral safe-area insets. Screens must not add their own horizontal padding + or `Math.min(width - padding * 2, N)` width constraints on the root. +- `useResponsiveLayout()` returns the resolved layout: `windowClass`, + `columns`, `gridGap`, `gutter`, `maxContentWidth`, `minTouchTarget`, + `headerHeight`, plus booleans `isCompact`, `isMedium`, `isExpanded`, + `isLarge`, `isLandscape`, `isShortLandscape`, `isSplitView` and + `isLargeText`. Prefer these over manual `Dimensions` logic. +- Short landscape phones reduce vertical chrome and use compact paddings. + Split-view/foldable windows use the medium class, so list grids become 2 + columns and forms stay readable. +- Large text (font scale ≥ 1.3) is exposed as `isLargeText` for screens that + need to relax fixed heights. +- Use `useResponsiveValue({ compact, medium, expanded, large })` when a single + value depends on the class. +- Text-heavy and form screens (vault detail, note detail/edit, notes, generator, + settings, vault health, privacy, open source) cap the wrapper at + `DesignSystem.layout.maxReadableWidth` (720). Home keeps the wider 960 grid + because it renders cards, not prose. +- List-grid cells must fill their column: wrap the item with `flex: 1` and + `maxWidth: ${100 / columns}%`, otherwise a two- or three-column FlatList + renders cards at their intrinsic width. +- Every hairline or border must declare its color. React Native defaults a + missing border color to black, which shows as dark separator lines on the + dark theme. Use `theme.colors.divider` for separators and + `theme.colors.border` for control outlines. + +Legacy helpers (`getDeviceType`, `getGridColumns`, `responsiveValue`, +`scaleSize`, `horizontalPadding`) remain exported for older screens, but new +code uses the resolved layout fields. + +## Components And Interaction + +- Minimum touch target: 44×44 dp; primary controls and icon buttons are 48×48 + (`DesignSystem.layout.touchTarget`). Icon-only controls always carry an + `accessibilityLabel` and an `accessibilityRole`. +- `Dialog` is the only confirmation/blocking-loading surface; it is responsive + (420–600 wide by class), uses `onPrimary` text and 48pt actions. Its header is + stacked: icon above the title, with title and description centered and + wrapping the full dialog width; the close control is a 48pt button pinned to + the top-right corner. +- `BottomSheet` is the only selection/contextual surface; it centers at 560–680 + width on larger windows and uses a modal `accessibilityViewIsModal` layer. + Close a sheet before opening a dialog (no competing native modals). +- `Toast` is for transient success/info feedback; it never blocks and stays + pointer-transparent through `style.pointerEvents`. +- `ListItem` is a 56pt row; `card` variant uses the `large` radius and the + elevated surface. +- `PasswordCard` is a 72pt tile with a 48pt clipboard action and a strength + dot; grids keep `gridGap` spacing. +- Use the shared `src/components/ui/switch` wrapper instead of importing + React Native's `Switch` directly: it applies the theme track/thumb colors and + the web `activeThumbColor` override consistently. +- Text inputs, buttons and segmented controls use the `md`/`lg` component + heights from `DesignSystem.components.heights`. + +## Accessibility + +- Text contrast target: 4.5:1 for normal text, 3:1 for large text and icons. + The color tokens are unit-tested against these targets. +- Do not hard-code `fontSize`; the type scale plus platform font scaling keeps + Dynamic Type usable. Dense chrome may cap scaling with + `maxFontSizeMultiplier` from `DesignSystem.accessibility`. +- All motion must respect reduced motion. Animations use opacity/transform only. +- Selection, disabled and pressed states must be exposed through + `accessibilityState`. +- Verify layouts at compact phone, short landscape, medium split view and + expanded/tablet widths, in light and dark mode. + +## Testing + +- `src/__tests__/design-system/DesignSystem.test.ts` checks token completeness, + spacing/radius scales, contrast ratios, type scale and layout metrics. +- `src/__tests__/design-system/ResponsiveLayout.test.ts` checks + `resolveLayout()` for every window class, landscape variants and text scale. +- `src/__tests__/compatibility/Responsive.test.tsx` keeps the legacy helper + behavior covered. diff --git a/docs/development.md b/docs/development.md index a872dd5..73d66f3 100644 --- a/docs/development.md +++ b/docs/development.md @@ -208,13 +208,15 @@ EAS build commands upload the project to expo.dev. Run them only after the relea ## UI And Accessibility Standards -- Use semantic tokens from `ThemeContext`; do not branch on literal background colors or introduce screen-local light/dark palettes. -- Keep main content within responsive bounds from `useResponsiveLayout`, including narrow phones, landscape, tablets, and split-view widths. +- Use the design system documented in [design-system.md](design-system.md). Colors come from `useTheme()` tokens; spacing, radii, icon sizes and text styles come from `DesignSystem`. Do not hard-code `fontSize`, hex colors or per-screen breakpoints. +- Build text with `DesignSystem.text('variant')`, not by spreading raw typography tokens (`size`/`weight` are not React Native style keys). +- `ScreenWrapper` owns the horizontal gutter, centered content width and lateral safe areas. Screens must not add root horizontal padding or `width - padding * 2` width constraints; use the resolved fields from `useResponsiveLayout` (`gutter`, `gridGap`, `columns`, `maxContentWidth`, `windowClass`, `isShortLandscape`, `isSplitView`, `isLargeText`). +- Design for compact phones, short landscape, medium split view and expanded/tablet widths in the same change. The window classes are compact (0–599), medium (600–839), expanded (840–1199) and large (1200+). - Icon-only controls must have `accessibilityRole`, `accessibilityLabel`, and a stable hit area. -- Interactive targets should be at least 44x44 points. +- Interactive targets must be at least 44x44 points; primary controls and icon buttons use 48x48 (`DesignSystem.layout.touchTarget`). - Use the shared `Dialog` for alerts and confirmations, `BottomSheet` for selection workflows, and `Toast` for non-blocking feedback. Do not add a parallel modal implementation without a platform requirement. - Custom modals and bottom sheets must expose modal semantics and keep decorative backdrops inaccessible. Close a bottom sheet before opening a global confirmation dialog. -- Animate only opacity and transforms, and keep `useReducedMotion` behavior intact. +- Animate only opacity and transforms, keep `usePrefersReducedMotion` behavior intact, and keep motion durations within `DesignSystem.motion`. - Category filters and segmented choices should expose selected state. - User-visible notification content must use localization keys in both Italian and English. - Keep profile-photo selection temporary until Save. Native photos must be copied into application-owned storage before their URI is written to preferences. diff --git a/docs/pre-build-security-ui-review.md b/docs/pre-build-security-ui-review.md index a0166ac..f27dc08 100644 --- a/docs/pre-build-security-ui-review.md +++ b/docs/pre-build-security-ui-review.md @@ -1,10 +1,10 @@ # Pre-Build Security And UI Review Original review date: 2026-06-17 -Latest update: 2026-08-09 +Latest update: 2026-10-23 -Current source release target: Keysoft 3.3.1, Android versionCode 130. Google Play remains on -Keysoft 3.3.0, versionCode 129, until the new production bundle is submitted manually. iOS +Current source release target: Keysoft 3.3.2, Android versionCode 131. Google Play remains on +Keysoft 3.3.1, versionCode 130, until the new production bundle is submitted manually. iOS simulator readiness remains outside the App Store release scope. ## Executive Summary @@ -13,6 +13,36 @@ The codebase passes local static and test verification, and the Android JS bundl The release-blocking privacy/config mismatch and the main accessibility issues were accepted for remediation after this review. Keep this file as the audit trail for the pre-build review. +### 2026-10-23 Design System And Responsive UI Review + +The mobile presentation layer was reviewed and migrated to the documented Nocturne v2 +design system. The review found and addressed: design tokens that existed but were not +consumed by screens; per-screen width math and literal font sizes; fixed dialog/sheet +widths; sub-44pt icon controls; dark-mode primary buttons using white text at ~2.5:1 +contrast; low-contrast tertiary/error text; an invalid raw-typography spread in +`VaultHealthScreen`; and a non-existent `theme.colors.surface` reference in `ListItem`. + +The change is presentation-only. It introduces a single layout source +(`constants/layout.ts`), compact/medium/expanded/large window classes, a 4pt spacing grid, +an explicit type scale with `DesignSystem.text()`, refreshed semantic colors +(`onPrimary`, mode-specific status colors, stronger tertiary text), one owner for the +screen gutter/content width (`ScreenWrapper`), responsive dialog/sheet widths, adaptive +PIN keypad sizing and 44/48pt touch targets. Documentation lives in +[design-system.md](design-system.md). It does not change permissions, network behavior, +storage keys, cryptographic formats, KDF parameters, icons/assets or the distribution +boundary. + +Verification for this review: strict TypeScript, ESLint, Prettier, and 36 Jest suites / +253 tests (including new token-scale, contrast and window-class tests). The Android bundle +export remains on the release checklist. Visual spot checks were performed in the Expo web +development target at compact phone, small-phone and tablet landscape widths, in dark and +light appearance. Keysoft 3.3.2 packages this review together with the production KDF +environment fix and the backup export/import progress feedback. + +The visual matrix below (compact phone, short landscape, medium split view, expanded +tablet, light/dark, reduced motion, large text) must be completed on a device or EAS +preview build before release. + ### 2026-08-09 Release Review The 3.3.1 release packages the 2026-08-08 security/code audit and GitHub presentation work. diff --git a/docs/release.md b/docs/release.md index 94e4a2f..4778f2f 100644 --- a/docs/release.md +++ b/docs/release.md @@ -2,8 +2,8 @@ ## Release Readiness Checklist -Current source release and Android build target: Keysoft 3.3.1, versionCode 130. Google Play -currently distributes Keysoft 3.3.0, versionCode 129, until the new app bundle completes +Current source release and Android build target: Keysoft 3.3.2, versionCode 131. Google Play +currently distributes Keysoft 3.3.1, versionCode 130, until the new app bundle completes review and is submitted manually. EAS remote versioning remains authoritative for Android builds. iOS remains a cloud-simulator compatibility target and is not an App Store release target. @@ -41,11 +41,12 @@ Application version data is maintained in: - `app.config.js` - Android native configuration where applicable -For the 3.3.1 source release, `app.config.js` uses `version: "3.3.1"`, -`android.versionCode: 130`, and an iOS simulator baseline of `ios.buildNumber: "1"`. Before -the build, the EAS remote Android value is 129; the production profile auto-increments it to 130. The local value mirrors the target for manifest visibility but does not control the EAS +For the 3.3.2 source release, `app.config.js` uses `version: "3.3.2"`, +`android.versionCode: 131`, and an iOS simulator baseline of `ios.buildNumber: "1"`. Before +the build, the EAS remote Android value is 130; the production profile auto-increments it to +131. The local value mirrors the target for manifest visibility but does not control the EAS counter. Because EAS Update uses the `appVersion` runtime policy, this release uses native -runtime 3.3.1. Google Play remains on 3.3.0/129 until manual submission and publication. +runtime 3.3.2. Google Play remains on 3.3.1/130 until manual submission and publication. Read the current remote value with `bunx eas-cli build:version:get --platform android --profile production --non-interactive` diff --git a/docs/releases/3.3.2.md b/docs/releases/3.3.2.md new file mode 100644 index 0000000..1faba60 --- /dev/null +++ b/docs/releases/3.3.2.md @@ -0,0 +1,67 @@ +# Keysoft 3.3.2 + +Source release: 2026-10-23. + +Android production target: versionCode 131. +Google Play status at release creation: Keysoft 3.3.1 / versionCode 130 remains published; +3.3.2 submission is a separate manual step. + +Keysoft 3.3.2 fixes a production authentication performance regression, hardens and +clarifies the backup flow, and packages the Nocturne v2 design-system refresh. It does not +change the KS1/KS1-PW1 payload layouts, storage keys, existing vault KDF parameters, Android +permissions, canonical assets, or the offline-only operating model. + +## Security And Performance + +- Restores native Argon2id in production builds. The Expo Go detection used + `Constants.expoGoConfig`, which is truthy in standalone builds because expo-constants + resolves it to the embedded update manifest; production therefore forced the pure-JS + PBKDF2 fallback (about 6-7 seconds per PIN unlock on a current phone), skipped the native + KDF and kept local notifications in history-only mode. Detection now relies on + `executionEnvironment` and `appOwnership`; new vaults use Argon2id and existing PBKDF2 + vaults upgrade transparently on the next PIN login. +- Keeps the shared vault writes serialised and the KDF upgrade/password-change transaction + crash-safe, including the pending recovery record and the batched re-encryption. +- Backup export and import now show a blocking progress dialog while the password-derived + key is calculated and the file is written, read or imported, instead of leaving the + settings screen apparently frozen. + +## UI/UX + +- Migrates the mobile presentation layer to the documented Nocturne v2 design system: + responsive compact/medium/expanded/large window classes, a 4pt spacing grid, an explicit + type scale, modern radii/elevation, contrast-checked semantic colors (`onPrimary`, + status colors, stronger tertiary text) and an accessibility token set. Documented in + `docs/design-system.md`. +- `ScreenWrapper` owns the screen gutter and readable content width; screens were migrated + off ad-hoc width math and literal sizes. Text/form screens cap at 720pt on tablets, the + vault grid fills two or three columns, and short-landscape, split-view/foldable windows + and the PIN keypad adapt to the window class. +- Dialog headers stack the icon above a wrapped, centered title/description with a 48pt + close control; Settings row separators use the divider token; vault cards fill their grid + slot; the decorative vault-health progress bar was removed; the generator uses the shared + switch wrapper; and the unlock screen groups identity and keypad evenly. +- Accessibility: 44/48pt touch targets, WCAG AA contrast for secondary/tertiary text and + status colors in both themes, accessible switch state, and reduced-motion-safe + transitions. + +## Verification + +- `bun install --frozen-lockfile` +- `bun run verify`: formatting, strict TypeScript, ESLint, 36 Jest suites / 253 tests, and + Expo Doctor 21/21 +- `bun audit --audit-level=critical`: no critical advisory; 87 lower-severity advisories + remain accepted dependency debt until compatible upstream versions are available +- `bun x expo export --platform android --output-dir C:\tmp\keysoft-android-export`: passed + (Hermes bytecode bundle, 4.7 MB) +- Generated-native permission, optimized R8/resource-shrinking and Argon2 keep-rule + inspection remains part of the pre-submission checklist for the EAS build +- Expo web development spot checks at 390x844, 320x568 and 1024x768 landscape, dark and + light appearance + +## Deployment Boundary + +The `v3.3.2` tag identifies the exact source used to start the EAS Android production build. +The EAS production profile produces an Android App Bundle and auto-increments the remote +version from 130 to 131. It does not submit to Google Play; store review and publication +remain manual. diff --git a/jest.config.js b/jest.config.js index 650eb53..addbaae 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,5 +1,8 @@ module.exports = { preset: 'jest-expo', + // Keep the transform/haste cache inside the project: avoids colliding with a running + // Metro server and works in shells that cannot write to the user temp directory. + cacheDirectory: '/tmp/jest-cache', setupFiles: [ '/jest.preload.js', require.resolve('jest-expo/src/preset/setup.js'), diff --git a/package.json b/package.json index 2aceab4..562f314 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "name": "Michael Gasperini", "url": "https://mikesoft.it" }, - "version": "3.3.1", + "version": "3.3.2", "description": "Private, offline-first password manager for Android. No accounts, ads, tracking, or cloud vault.", "keywords": [ "android", @@ -27,17 +27,17 @@ "start:tunnel": "expo start --go --tunnel", "android": "expo start --android --go", "web": "expo start --web", - "build:android:preview": "bunx eas-cli build --platform android --profile preview", - "build:android:production": "bunx eas-cli build --platform android --profile production", - "build:ios:preview": "bunx eas-cli build --platform ios --profile preview", - "build:ios:simulator": "bunx eas-cli build --platform ios --profile ios-simulator", - "submit:android:production": "bunx eas-cli submit --platform android --profile production", + "build:android:preview": "bun x eas-cli build --platform android --profile preview", + "build:android:production": "bun x eas-cli build --platform android --profile production", + "build:ios:preview": "bun x eas-cli build --platform ios --profile preview", + "build:ios:simulator": "bun x eas-cli build --platform ios --profile ios-simulator", + "submit:android:production": "bun x eas-cli submit --platform android --profile production", "lint": "eslint .", "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,md}\"", "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,md}\"", "test": "jest", "test:ci": "jest --ci --coverage --runInBand", - "verify": "bun run format:check && bun run typecheck && bun run lint && bun run test:ci && bunx expo-doctor", + "verify": "bun run format:check && bun run typecheck && bun run lint && bun run test:ci && bun x expo-doctor", "deps:audit": "bun audit", "typecheck": "tsc --noEmit" }, @@ -64,7 +64,7 @@ "expo-local-authentication": "~57.0.3", "expo-localization": "~57.0.2", "expo-navigation-bar": "~57.0.3", - "expo-notifications": "~57.0.23", + "expo-notifications": "~57.0.24", "expo-screen-capture": "~57.0.4", "expo-secure-store": "~57.0.4", "expo-sharing": "~57.0.24", diff --git a/src/__tests__/architecture/Structure.test.ts b/src/__tests__/architecture/Structure.test.ts index a690604..ab7f189 100644 --- a/src/__tests__/architecture/Structure.test.ts +++ b/src/__tests__/architecture/Structure.test.ts @@ -51,4 +51,15 @@ describe('source structure', () => { expect(settingsScreen).toContain("from '../hooks/settings/useExportImport'"); expect(settingsScreen).not.toContain("from './Settings/hooks/"); }); + + it('paints Settings row separators with the theme divider token', () => { + const settingsScreen = fs.readFileSync( + path.join(srcRoot, 'screens/SettingsScreen.tsx'), + 'utf8', + ); + + // A bare compactRow style would fall back to React Native's black default border. + expect(settingsScreen).not.toMatch(/style=\{styles\.compactRow\}/); + expect(settingsScreen).toContain('borderBottomColor: theme.colors.divider'); + }); }); diff --git a/src/__tests__/design-system/DesignSystem.test.ts b/src/__tests__/design-system/DesignSystem.test.ts new file mode 100644 index 0000000..4750cfc --- /dev/null +++ b/src/__tests__/design-system/DesignSystem.test.ts @@ -0,0 +1,114 @@ +import { + DIALOG_MAX_WIDTH, + LAYOUT, + MODAL_MAX_WIDTH, + TOUCH_TARGET, + WINDOW_METRICS, + WINDOW_SIZE_CLASSES, +} from '../../constants/layout'; +import { DesignSystem, textStyle } from '../../constants/designSystem'; +import { DarkTheme, LightTheme } from '../../constants/theme'; + +function luminance(hex: string): number { + const clean = hex.replace('#', ''); + const channels = [0, 2, 4].map((index) => parseInt(clean.slice(index, index + 2), 16) / 255); + const linear = channels.map((value) => + value <= 0.03928 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4), + ); + return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]; +} + +function contrast(foreground: string, background: string): number { + const [lighter, darker] = [luminance(foreground), luminance(background)].sort((a, b) => b - a); + return (lighter + 0.05) / (darker + 0.05); +} + +describe('design system tokens', () => { + it('keeps the spacing scale on the 4pt grid and strictly increasing', () => { + const values = Object.values(DesignSystem.spacing); + expect(DesignSystem.spacing.s).toBe(8); + expect(DesignSystem.spacing.m).toBe(12); + expect(DesignSystem.spacing.l).toBe(16); + expect(DesignSystem.spacing.xl).toBe(24); + + const ordered = values.filter((value) => value > 0).sort((a, b) => a - b); + expect(new Set(ordered).size).toBe(ordered.length); + ordered.forEach((value) => expect(value % 2).toBe(0)); + }); + + it('keeps the radius scale increasing and non-negative', () => { + const values = Object.values(DesignSystem.radii); + expect(values).toEqual([...values].sort((a, b) => a - b)); + values.forEach((value) => expect(value).toBeGreaterThanOrEqual(0)); + }); + + it('provides a readable type scale', () => { + const variants = Object.entries(DesignSystem.typography); + expect(variants.length).toBeGreaterThanOrEqual(10); + + variants.forEach(([, token]) => { + expect(token.size).toBeGreaterThanOrEqual(11); + expect(token.lineHeight).toBeGreaterThanOrEqual(token.size); + expect(['400', '500', '600', '700']).toContain(token.weight); + }); + + expect(DesignSystem.typography.body.size).toBe(16); + expect(DesignSystem.typography.caption.size).toBe(12); + }); + + it('exposes a valid text style helper', () => { + expect(textStyle('body')).toEqual({ + fontSize: 16, + lineHeight: 24, + fontWeight: '400', + letterSpacing: 0, + }); + expect(textStyle('title', '#123456')).toMatchObject({ fontSize: 20, color: '#123456' }); + }); + + it('keeps touch targets at accessible sizes', () => { + expect(TOUCH_TARGET.minimum).toBeGreaterThanOrEqual(44); + expect(TOUCH_TARGET.comfortable).toBeGreaterThanOrEqual(48); + expect(DesignSystem.layout.touchTarget.comfortable).toBeGreaterThanOrEqual(48); + expect(LAYOUT.listItemMinHeight).toBeGreaterThanOrEqual(44); + }); + + it('defines ordered breakpoints and monotonic layout metrics', () => { + const widths = WINDOW_SIZE_CLASSES.map((sizeClass) => WINDOW_METRICS[sizeClass].gutter); + expect(widths).toEqual([16, 24, 32, 32]); + + const contentMaxWidths = WINDOW_SIZE_CLASSES.map( + (sizeClass) => WINDOW_METRICS[sizeClass].contentMaxWidth, + ); + expect(contentMaxWidths).toEqual([Infinity, 720, 960, 1120]); + + const columns = WINDOW_SIZE_CLASSES.map((sizeClass) => WINDOW_METRICS[sizeClass].columns); + expect(columns).toEqual([1, 2, 2, 3]); + + const dialogWidths = WINDOW_SIZE_CLASSES.map((sizeClass) => DIALOG_MAX_WIDTH[sizeClass]); + expect(dialogWidths).toEqual([...dialogWidths].sort((a, b) => a - b)); + const modalWidths = WINDOW_SIZE_CLASSES.map((sizeClass) => MODAL_MAX_WIDTH[sizeClass]); + expect(modalWidths).toEqual([...modalWidths].sort((a, b) => a - b)); + }); + + it('ships identical semantic color roles in both themes', () => { + expect(Object.keys(LightTheme.colors).sort()).toEqual(Object.keys(DarkTheme.colors).sort()); + }); + + it.each([ + ['text', 'background'], + ['textSecondary', 'background'], + ['textTertiary', 'background'], + ['textTertiary', 'backgroundElevated'], + ['error', 'background'], + ['success', 'background'], + ['warning', 'background'], + ['info', 'background'], + ['onPrimary', 'primary'], + ] as const)('meets WCAG AA contrast for %s on %s in both themes', (foreground, background) => { + [LightTheme, DarkTheme].forEach((theme) => { + const ratio = contrast(theme.colors[foreground], theme.colors[background]); + expect(ratio).toBeGreaterThanOrEqual(4.5); + }); + }); +}); diff --git a/src/__tests__/design-system/ResponsiveLayout.test.ts b/src/__tests__/design-system/ResponsiveLayout.test.ts new file mode 100644 index 0000000..f5a23f3 --- /dev/null +++ b/src/__tests__/design-system/ResponsiveLayout.test.ts @@ -0,0 +1,69 @@ +import { BREAKPOINTS } from '../../constants/layout'; +import { resolveLayout } from '../../utils/responsive'; + +describe('resolveLayout', () => { + it('resolves a compact phone', () => { + const layout = resolveLayout(375, 812); + + expect(layout.windowClass).toBe('compact'); + expect(layout.deviceType).toBe('phone'); + expect(layout.columns).toBe(1); + expect(layout.gutter).toBe(16); + expect(layout.maxContentWidth).toBe(Infinity); + expect(layout.contentWidth).toBe(375); + expect(layout.minTouchTarget).toBe(44); + expect(layout.isCompact).toBe(true); + expect(layout.isShortLandscape).toBe(false); + expect(layout.isSplitView).toBe(false); + }); + + it('resolves a medium split-view window with two columns', () => { + const layout = resolveLayout(800, 500); + + expect(layout.windowClass).toBe('medium'); + expect(layout.columns).toBe(2); + expect(layout.gutter).toBe(24); + expect(layout.maxContentWidth).toBe(720); + expect(layout.contentWidth).toBe(720); + expect(layout.minTouchTarget).toBe(48); + expect(layout.isLandscape).toBe(true); + expect(layout.isSplitView).toBe(true); + }); + + it('resolves an expanded tablet window', () => { + const layout = resolveLayout(1024, 768); + + expect(layout.windowClass).toBe('expanded'); + expect(layout.columns).toBe(2); + expect(layout.gutter).toBe(32); + expect(layout.maxContentWidth).toBe(960); + expect(layout.isExpanded).toBe(true); + expect(layout.isTablet).toBe(true); + }); + + it('resolves a large window with three columns', () => { + const layout = resolveLayout(1280, 800); + + expect(layout.windowClass).toBe('large'); + expect(layout.columns).toBe(3); + expect(layout.maxContentWidth).toBe(1120); + expect(layout.headerHeight).toBe(72); + expect(layout.isTV).toBe(true); + }); + + it('detects short landscape phones', () => { + const layout = resolveLayout(800, 360); + + expect(layout.isLandscape).toBe(true); + expect(layout.isShortLandscape).toBe(true); + }); + + it('flags large accessibility text', () => { + expect(resolveLayout(375, 812, 1).isLargeText).toBe(false); + expect(resolveLayout(375, 812, 1.3).isLargeText).toBe(true); + }); + + it('exposes the canonical breakpoints', () => { + expect(BREAKPOINTS).toEqual({ compact: 0, medium: 600, expanded: 840, large: 1200 }); + }); +}); diff --git a/src/__tests__/services/CryptoServiceEnvironment.test.ts b/src/__tests__/services/CryptoServiceEnvironment.test.ts new file mode 100644 index 0000000..5949041 --- /dev/null +++ b/src/__tests__/services/CryptoServiceEnvironment.test.ts @@ -0,0 +1,42 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ + +function loadCryptoService( + constants: unknown, +): typeof import('../../services/crypto/cryptoService') { + jest.resetModules(); + jest.doMock('expo-constants', () => ({ __esModule: true, default: constants })); + + let service: typeof import('../../services/crypto/cryptoService') | undefined; + jest.isolateModules(() => { + service = require('../../services/crypto/cryptoService'); + }); + return service as typeof import('../../services/crypto/cryptoService'); +} + +describe('CryptoService KDF selection', () => { + it('loads the native Argon2 KDF in a production-like standalone build', () => { + jest.doMock('react-native-argon2', () => ({ + __esModule: true, + default: jest.fn(async () => ({ rawHash: 'ab'.repeat(32) })), + })); + + const cryptoService = loadCryptoService({ + executionEnvironment: 'bare', + appOwnership: null, + expoGoConfig: { id: 'embedded-update', commitTime: 1, assets: [] }, + manifest: { id: 'embedded-update', commitTime: 1, assets: [] }, + }); + + expect(cryptoService.isNativeKdfAvailable()).toBe(true); + }); + + it('keeps the PBKDF2 fallback only for Expo Go', () => { + const argon2 = jest.fn(async () => ({ rawHash: 'ab'.repeat(32) })); + jest.doMock('react-native-argon2', () => ({ __esModule: true, default: argon2 })); + + const cryptoService = loadCryptoService({ executionEnvironment: 'storeClient' }); + + expect(cryptoService.isNativeKdfAvailable()).toBe(false); + expect(argon2).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/utils/env.test.ts b/src/__tests__/utils/env.test.ts new file mode 100644 index 0000000..203c9e3 --- /dev/null +++ b/src/__tests__/utils/env.test.ts @@ -0,0 +1,49 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ + +function isExpoGoWith(constants: unknown): boolean { + jest.resetModules(); + jest.doMock('expo-constants', () => ({ __esModule: true, default: constants })); + + let result = false; + jest.isolateModules(() => { + result = (require('../../utils/env') as typeof import('../../utils/env')).isExpoGo(); + }); + return result; +} + +describe('isExpoGo', () => { + it('detects Expo Go from the store client execution environment', () => { + expect(isExpoGoWith({ executionEnvironment: 'storeClient' })).toBe(true); + }); + + it('detects Expo Go from the legacy app ownership flag', () => { + expect(isExpoGoWith({ executionEnvironment: 'bare', appOwnership: 'expo' })).toBe(true); + }); + + it('does not treat the embedded standalone manifest as Expo Go', () => { + // expo-constants resolves expoGoConfig to the embedded update manifest in standalone + // builds (the manifest has no metadata key), so it must not be used as a signal. + expect( + isExpoGoWith({ + executionEnvironment: 'bare', + appOwnership: null, + expoGoConfig: { id: 'embedded-update', commitTime: 1, assets: [] }, + manifest: { id: 'embedded-update', commitTime: 1, assets: [] }, + }), + ).toBe(false); + }); + + it('does not treat a standalone build as Expo Go', () => { + expect(isExpoGoWith({ executionEnvironment: 'standalone' })).toBe(false); + }); + + it('returns false when the constants module throws', () => { + const throwingConstants = { + get executionEnvironment(): string { + throw new Error('native constants unavailable'); + }, + }; + + expect(isExpoGoWith(throwingConstants)).toBe(false); + }); +}); diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index 3ec9078..54c5079 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -1,9 +1,9 @@ import React, { Component, ReactNode } from 'react'; import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; +import { DesignSystem } from '../constants/designSystem'; import { useTheme } from '../contexts/ThemeContext'; import { useLanguage } from '../contexts/LanguageContext'; -import { AppTheme } from '../constants/theme'; import Logger from '../utils/logger'; interface ErrorBoundaryProps { @@ -23,9 +23,17 @@ const ErrorFallback: React.FC<{ onReset: () => void }> = ({ onReset }) => { return ( - - {t('error')} - + + + {t('error')} + + {t('generic_error')} void }> = ({ onReset }) => { accessibilityRole="button" accessibilityLabel={t('retry')} > - {t('retry')} + + {t('retry')} + ); @@ -73,28 +83,24 @@ const styles = StyleSheet.create({ flex: 1, justifyContent: 'center', alignItems: 'center', - padding: AppTheme.spacing.xl, + padding: DesignSystem.spacing.xl, }, title: { - fontSize: AppTheme.fonts.sizes.xlarge, - fontWeight: 'bold', - marginTop: AppTheme.spacing.l, - marginBottom: AppTheme.spacing.s, + marginTop: DesignSystem.spacing.l, + marginBottom: DesignSystem.spacing.s, textAlign: 'center', }, message: { - fontSize: AppTheme.fonts.sizes.medium, textAlign: 'center', - marginBottom: AppTheme.spacing.xl, + marginBottom: DesignSystem.spacing.xl, }, button: { - paddingHorizontal: AppTheme.spacing.xl, - paddingVertical: AppTheme.spacing.m, - borderRadius: AppTheme.borderRadius.pill, - }, - buttonText: { - fontSize: AppTheme.fonts.sizes.medium, - fontWeight: 'bold', + minHeight: DesignSystem.layout.touchTarget.comfortable, + paddingHorizontal: DesignSystem.spacing.xl, + paddingVertical: DesignSystem.spacing.m, + borderRadius: DesignSystem.radii.pill, + alignItems: 'center', + justifyContent: 'center', }, }); diff --git a/src/components/NotificationBell.tsx b/src/components/NotificationBell.tsx index e4d5485..8ec765b 100644 --- a/src/components/NotificationBell.tsx +++ b/src/components/NotificationBell.tsx @@ -5,6 +5,7 @@ import { Ionicons } from '@expo/vector-icons'; import { useAlert } from '../contexts/AlertContext'; import { useLanguage } from '../contexts/LanguageContext'; import { useTheme } from '../contexts/ThemeContext'; +import { DesignSystem } from '../constants/designSystem'; import NotificationService, { NotificationType } from '../services/utils/notificationService'; import { BottomSheet } from './ui/bottom-sheet'; import { MotionPressable } from './ui/motion'; @@ -108,14 +109,18 @@ const NotificationBell: React.FC = ({ onPress }) => { { onPress?.(); if (!onPress) setIsOpen(true); }} style={styles.bell} > - + {unreadCount > 0 ? ( = ({ onPress }) => { onPress={() => void markAllAsRead()} style={[styles.toolButton, { borderColor: theme.colors.border }]} > - + {t('mark_all_read')} @@ -154,7 +163,11 @@ const NotificationBell: React.FC = ({ onPress }) => { onPress={confirmDeleteAll} style={[styles.toolIcon, { borderColor: theme.colors.border }]} > - + ) : null} @@ -165,7 +178,7 @@ const NotificationBell: React.FC = ({ onPress }) => { @@ -207,7 +220,7 @@ const NotificationBell: React.FC = ({ onPress }) => { @@ -231,11 +244,15 @@ const NotificationBell: React.FC = ({ onPress }) => { void deleteNotification(notification.id)} style={styles.deleteButton} > - + ))} @@ -268,70 +285,108 @@ function getNotificationIcon(type: NotificationType): keyof typeof Ionicons.glyp } const styles = StyleSheet.create({ - bell: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, + bell: { + width: DesignSystem.layout.touchTarget.comfortable, + height: DesignSystem.layout.touchTarget.comfortable, + alignItems: 'center', + justifyContent: 'center', + }, badge: { position: 'absolute', - right: 9, - top: 8, - width: 7, - height: 7, - borderRadius: 4, + right: 11, + top: 10, + width: 8, + height: 8, + borderRadius: DesignSystem.radii.pill, borderWidth: 1, }, - tools: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 10 }, + tools: { + flexDirection: 'row', + alignItems: 'center', + gap: DesignSystem.spacing.s, + marginBottom: DesignSystem.spacing.m, + }, toolButton: { flex: 1, - minHeight: 42, + minHeight: DesignSystem.layout.touchTarget.comfortable, borderWidth: 1, - borderRadius: 8, - paddingHorizontal: 11, + borderRadius: DesignSystem.radii.medium, + paddingHorizontal: DesignSystem.spacing.m, flexDirection: 'row', alignItems: 'center', - gap: 8, + gap: DesignSystem.spacing.s, }, toolIcon: { - width: 44, - height: 42, + width: DesignSystem.layout.touchTarget.comfortable, + height: DesignSystem.layout.touchTarget.comfortable, borderWidth: 1, - borderRadius: 8, + borderRadius: DesignSystem.radii.medium, alignItems: 'center', justifyContent: 'center', }, - toolText: { fontSize: 12, lineHeight: 16, fontWeight: '600' }, - empty: { alignItems: 'center', paddingHorizontal: 22, paddingVertical: 46 }, + toolText: { ...DesignSystem.text('labelSmall'), fontWeight: '600' }, + empty: { + alignItems: 'center', + paddingHorizontal: DesignSystem.spacing.xl, + paddingVertical: DesignSystem.spacing.xxxl, + }, emptyIcon: { - width: 52, - height: 52, - borderRadius: 13, + width: 56, + height: 56, + borderRadius: DesignSystem.radii.large, alignItems: 'center', justifyContent: 'center', - marginBottom: 15, + marginBottom: DesignSystem.spacing.l, + }, + emptyTitle: { ...DesignSystem.text('subtitle'), textAlign: 'center' }, + emptyBody: { + ...DesignSystem.text('bodySmall'), + marginTop: DesignSystem.spacing.s, + textAlign: 'center', + maxWidth: 320, + }, + list: { + borderWidth: 1, + borderRadius: DesignSystem.radii.medium, + overflow: 'hidden', + }, + notification: { + minHeight: 88, + flexDirection: 'row', + alignItems: 'center', }, - emptyTitle: { fontSize: 16, lineHeight: 21, fontWeight: '600', textAlign: 'center' }, - emptyBody: { marginTop: 6, fontSize: 13, lineHeight: 19, textAlign: 'center', maxWidth: 280 }, - list: { borderWidth: 1, borderRadius: 10, overflow: 'hidden' }, - notification: { minHeight: 82, flexDirection: 'row', alignItems: 'center' }, notificationMain: { flex: 1, minWidth: 0, - paddingLeft: 10, - paddingVertical: 10, + paddingLeft: DesignSystem.spacing.m, + paddingVertical: DesignSystem.spacing.m, flexDirection: 'row', alignItems: 'flex-start', - gap: 10, + gap: DesignSystem.spacing.m, }, notificationIcon: { - width: 34, - height: 34, - borderRadius: 8, + width: 40, + height: 40, + borderRadius: DesignSystem.radii.medium, alignItems: 'center', justifyContent: 'center', }, notificationCopy: { flex: 1, minWidth: 0 }, - notificationTitle: { fontSize: 13, lineHeight: 18, fontWeight: '600' }, - notificationBody: { fontSize: 12, lineHeight: 17, marginTop: 2 }, - notificationTime: { fontSize: 10, lineHeight: 14, marginTop: 4 }, - deleteButton: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, + notificationTitle: { ...DesignSystem.text('bodySmall'), fontWeight: '600' }, + notificationBody: { + ...DesignSystem.text('caption'), + marginTop: DesignSystem.spacing.xxs, + }, + notificationTime: { + ...DesignSystem.text('micro'), + marginTop: DesignSystem.spacing.xs, + }, + deleteButton: { + width: DesignSystem.layout.touchTarget.comfortable, + height: DesignSystem.layout.touchTarget.comfortable, + alignItems: 'center', + justifyContent: 'center', + }, }); export default NotificationBell; diff --git a/src/components/PasswordCard.tsx b/src/components/PasswordCard.tsx index d55a5a1..11ec652 100644 --- a/src/components/PasswordCard.tsx +++ b/src/components/PasswordCard.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useMemo } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; +import { DesignSystem } from '../constants/designSystem'; import { Password } from '../models/Password'; import { useLanguage } from '../contexts/LanguageContext'; import { useTheme } from '../contexts/ThemeContext'; @@ -69,14 +70,20 @@ const PasswordCard = React.memo(({ password, onPress }: PasswordCardProps) => { style={styles.cardMain} > - + - + {password.title} - + {password.username} @@ -90,9 +97,13 @@ const PasswordCard = React.memo(({ password, onPress }: PasswordCardProps) => { accessibilityLabel={t('copy_password_card')} accessibilityRole="button" onPress={() => void handleCopyToClipboard()} - style={styles.copyButton} + style={[styles.copyButton, { backgroundColor: theme.colors.backgroundMuted }]} > - + ); @@ -102,33 +113,46 @@ PasswordCard.displayName = 'PasswordCard'; const styles = StyleSheet.create({ card: { - minHeight: 58, - borderBottomWidth: StyleSheet.hairlineWidth, - borderRadius: 0, + minHeight: 72, + borderWidth: 1, + borderRadius: DesignSystem.radii.large, flexDirection: 'row', alignItems: 'center', + paddingRight: DesignSystem.spacing.s, + overflow: 'hidden', }, cardMain: { flex: 1, minWidth: 0, - minHeight: 58, - paddingLeft: 10, + minHeight: 72, + paddingLeft: DesignSystem.spacing.m, + paddingVertical: DesignSystem.spacing.m, flexDirection: 'row', alignItems: 'center', }, icon: { - width: 34, - height: 34, - borderRadius: 7, + width: 40, + height: 40, + borderRadius: DesignSystem.radii.medium, alignItems: 'center', justifyContent: 'center', - marginRight: 10, + marginRight: DesignSystem.spacing.m, }, copy: { flex: 1, minWidth: 0 }, - title: { fontSize: 14, lineHeight: 18, fontWeight: '600' }, - username: { fontSize: 11, lineHeight: 15, marginTop: 1 }, - strengthDot: { width: 5, height: 5, borderRadius: 3, marginHorizontal: 8 }, - copyButton: { width: 46, height: 44, alignItems: 'center', justifyContent: 'center' }, + title: { fontWeight: '600' }, + strengthDot: { + width: 6, + height: 6, + borderRadius: DesignSystem.radii.pill, + marginHorizontal: DesignSystem.spacing.s, + }, + copyButton: { + width: DesignSystem.layout.touchTarget.comfortable, + height: DesignSystem.layout.touchTarget.comfortable, + borderRadius: DesignSystem.radii.medium, + alignItems: 'center', + justifyContent: 'center', + }, }); export default PasswordCard; diff --git a/src/components/ScreenWrapper.tsx b/src/components/ScreenWrapper.tsx index 3a735f2..927721a 100644 --- a/src/components/ScreenWrapper.tsx +++ b/src/components/ScreenWrapper.tsx @@ -9,47 +9,38 @@ interface ScreenWrapperProps { } /** - * Componente wrapper generico per gli schermi dell'applicazione. - * Applies safe-area and responsive content bounds. Global providers live in App.js. + * Shared screen frame for the Keysoft design system. + * + * Owns the horizontal gutter, centered content width and lateral safe areas so + * screens only deal with vertical composition. Compact windows use the 16pt + * gutter; medium/expanded/large windows center content at their content width. */ const ScreenWrapper: React.FC = ({ children, style }) => { const insets = useSafeAreaInsets(); - const responsive = useResponsiveLayout(); + const layout = useResponsiveLayout(); return ( - {responsive.isLargeScreen ? ( - - - {children} - - - ) : ( - {children} - )} + width: '100%', + maxWidth: layout.maxContentWidth, + paddingHorizontal: layout.gutter, + }, + style, + ]} + > + {children} + ); }; diff --git a/src/components/ui/bottom-sheet.tsx b/src/components/ui/bottom-sheet.tsx index 1d4845c..6775977 100644 --- a/src/components/ui/bottom-sheet.tsx +++ b/src/components/ui/bottom-sheet.tsx @@ -5,19 +5,20 @@ import { Image, ImageSourcePropType, Modal, - Platform, StyleSheet, Text, - useWindowDimensions, View, } from 'react-native'; import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'; import { Ionicons } from '@expo/vector-icons'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { DesignSystem } from '../../constants/designSystem'; +import { MODAL_MAX_WIDTH } from '../../constants/layout'; import { useLanguage } from '../../contexts/LanguageContext'; import { useTheme } from '../../contexts/ThemeContext'; import { MotionPressable, usePrefersReducedMotion } from './motion'; +import { useResponsiveLayout } from '../../utils/responsive'; type IoniconName = keyof typeof Ionicons.glyphMap; @@ -41,14 +42,14 @@ export function BottomSheet({ const { theme } = useTheme(); const { t } = useLanguage(); const insets = useSafeAreaInsets(); - const window = useWindowDimensions(); + const layout = useResponsiveLayout(); const isReducedMotion = usePrefersReducedMotion(); - const translateY = React.useRef(new Animated.Value(window.height)).current; + const translateY = React.useRef(new Animated.Value(layout.height)).current; const opacity = React.useRef(new Animated.Value(0)).current; React.useEffect(() => { if (!visible) { - translateY.setValue(window.height); + translateY.setValue(layout.height); opacity.setValue(0); return; } @@ -68,16 +69,16 @@ export function BottomSheet({ }), Animated.timing(opacity, { toValue: 1, - duration: 180, + duration: DesignSystem.motion.durations.short, useNativeDriver: true, }), ]).start(); - }, [isReducedMotion, opacity, translateY, visible, window.height]); + }, [isReducedMotion, layout.height, opacity, translateY, visible]); const maxHeight = height === 'full' - ? window.height - Math.max(insets.top, 12) - : window.height * (height === 'half' ? 0.58 : 0.78); + ? layout.height - Math.max(insets.top, 12) + : layout.height * (height === 'half' ? 0.6 : 0.8); return ( = 768 && styles.tabletContainer, theme.shadows.large, ]} > - {title} + + {title} + {showCloseButton ? ( - + ) : null} @@ -196,20 +199,34 @@ export function BottomSheetOption({ ) : iconEmoji ? ( {iconEmoji} ) : ( - + )} ) : null} - {label} + + {label} + {value ? ( - {value} + + {value} + ) : null} {selected ? ( - + ) : ( - + )} @@ -251,15 +268,15 @@ export function BottomSheetButton({ ]} > {loading ? ( - + ) : ( = 340; + const hasHorizontalActions = actions.length === 2 && layout.width >= 340; + const maxHeight = Math.min( + layout.height - Math.max(insets.top, 12) - Math.max(insets.bottom, 12), + 720, + ); return ( - - {iconElement ?? } - - {title} {dismissible ? ( - + - ) : ( - - )} - - - + ) : null} + + {iconElement ?? ( + + )} + + + {title} + {description ? ( - + {description} ) : null} - {children} + {children ? {children} : null} + {actions.length > 0 ? ( createStyles(theme), [theme]); - // Helper per renderizzare l'icona sinistra const renderLeftIcon = () => { if (!leftIcon) return null; @@ -60,7 +60,7 @@ export function ListItem({ > @@ -70,7 +70,6 @@ export function ListItem({ return {leftIcon}; }; - // Helper per renderizzare l'icona destra const renderRightIcon = () => { if (!rightIcon) return null; @@ -78,7 +77,7 @@ export function ListItem({ return ( ); @@ -105,8 +104,14 @@ export function ListItem({ {renderLeftIcon()} - {title} - {description && {description}} + + {title} + + {description ? ( + + {description} + + ) : null} @@ -117,74 +122,70 @@ export function ListItem({ ); } -const createStyles = (theme: any) => +const createStyles = (theme: Theme) => StyleSheet.create({ container: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - paddingHorizontal: 8, - paddingVertical: 7, - minHeight: 48, + paddingHorizontal: DesignSystem.spacing.m, + paddingVertical: DesignSystem.spacing.m, + minHeight: DesignSystem.layout.listItemMinHeight, }, cardContainer: { - backgroundColor: theme.colors.surface, - borderRadius: 12, - marginBottom: 8, - paddingVertical: 14, + backgroundColor: theme.colors.backgroundElevated, + borderRadius: DesignSystem.radii.large, + marginBottom: DesignSystem.spacing.m, + paddingVertical: DesignSystem.spacing.l, borderWidth: 1, borderColor: theme.colors.border, }, containerSelected: { - backgroundColor: theme.colors.primary + '15', + backgroundColor: theme.colors.listItemSelected, borderWidth: 1, borderColor: theme.colors.primary, }, disabled: { - opacity: 0.5, + opacity: DesignSystem.state.disabled, }, leftSection: { flexDirection: 'row', alignItems: 'center', flex: 1, - minWidth: 0, // Important for text truncation + minWidth: 0, }, iconContainer: { - marginRight: 10, + marginRight: DesignSystem.spacing.m, }, cardIconContainer: { width: 40, height: 40, - borderRadius: 10, + borderRadius: DesignSystem.radii.medium, justifyContent: 'center', alignItems: 'center', }, textContainer: { flex: 1, - minWidth: 0, // Important for text truncation + minWidth: 0, }, title: { - fontSize: 13, - lineHeight: 18, + ...DesignSystem.text('bodySmall', theme.colors.text), fontWeight: '500', - color: theme.colors.text, }, titleSelected: { fontWeight: '600', color: theme.colors.text, }, description: { - fontSize: 10, - lineHeight: 14, - color: theme.colors.textSecondary, - marginTop: 2, + ...DesignSystem.text('caption', theme.colors.textSecondary), + marginTop: DesignSystem.spacing.xxs, }, rightSection: { flexDirection: 'row', alignItems: 'center', - gap: 8, - marginLeft: 8, - flexShrink: 0, // Prevent shrinking + gap: DesignSystem.spacing.s, + marginLeft: DesignSystem.spacing.s, + flexShrink: 0, }, }); diff --git a/src/components/ui/pin-keypad.tsx b/src/components/ui/pin-keypad.tsx index 2981f05..5b3c7eb 100644 --- a/src/components/ui/pin-keypad.tsx +++ b/src/components/ui/pin-keypad.tsx @@ -1,7 +1,9 @@ import React from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; +import { DesignSystem } from '../../constants/designSystem'; import { useTheme } from '../../contexts/ThemeContext'; +import { useResponsiveLayout } from '../../utils/responsive'; import { MotionPressable } from './motion'; interface PinKeypadProps { @@ -32,6 +34,12 @@ export function PinKeypad({ onComplete, }: PinKeypadProps) { const { theme } = useTheme(); + const layout = useResponsiveLayout(); + const keyHeight = layout.isShortLandscape + ? DesignSystem.layout.touchTarget.minimum + : layout.isTabletOrLarger + ? 60 + : 52; function appendDigit(digit: string) { if (disabled || value.length >= maxLength) return; @@ -62,12 +70,15 @@ export function PinKeypad({ style={[ styles.key, { + minHeight: keyHeight, backgroundColor: theme.colors.background, borderColor: theme.colors.border, }, ]} > - {digit} + + {digit} + ))} @@ -79,11 +90,11 @@ export function PinKeypad({ accessibilityLabel={biometricLabel} disabled={disabled || !onBiometricPress} onPress={onBiometricPress} - style={styles.utilityKey} + style={[styles.utilityKey, { minHeight: keyHeight }]} > @@ -96,12 +107,13 @@ export function PinKeypad({ style={[ styles.key, { + minHeight: keyHeight, backgroundColor: theme.colors.background, borderColor: theme.colors.border, }, ]} > - 0 + 0 - + @@ -121,31 +137,25 @@ export function PinKeypad({ const styles = StyleSheet.create({ keypad: { width: '100%', - maxWidth: 300, + maxWidth: 340, alignSelf: 'center', - gap: 8, + gap: DesignSystem.spacing.s, }, row: { flexDirection: 'row', - gap: 8, + gap: DesignSystem.spacing.s, }, key: { flex: 1, - minHeight: 50, + borderRadius: DesignSystem.radii.medium, borderWidth: StyleSheet.hairlineWidth, - borderRadius: 8, alignItems: 'center', justifyContent: 'center', }, utilityKey: { flex: 1, - minHeight: 50, + borderRadius: DesignSystem.radii.medium, alignItems: 'center', justifyContent: 'center', }, - keyText: { - fontSize: 20, - lineHeight: 24, - fontWeight: '600', - }, }); diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx index ef85157..5880b3f 100644 --- a/src/components/ui/switch.tsx +++ b/src/components/ui/switch.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Switch as RNSwitch } from 'react-native'; +import { Platform, Switch as RNSwitch } from 'react-native'; import { useTheme } from '../../contexts/ThemeContext'; interface SwitchProps { @@ -18,16 +18,26 @@ export function Switch({ accessibilityLabel, }: SwitchProps) { const { theme } = useTheme(); + // react-native-web uses its own teal default for the active thumb unless + // activeThumbColor is provided; native ignores this web-only prop. + const webProps = + Platform.OS === 'web' + ? ({ activeThumbColor: theme.colors.switchThumbOn } as Record) + : {}; + return ( ); } diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx index 4af45f2..04aa4b3 100644 --- a/src/components/ui/toast.tsx +++ b/src/components/ui/toast.tsx @@ -4,6 +4,8 @@ import { Ionicons } from '@expo/vector-icons'; import Reanimated, { FadeInDown, FadeOutUp } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { DesignSystem } from '../../constants/designSystem'; +import { LAYOUT } from '../../constants/layout'; import { useTheme } from '../../contexts/ThemeContext'; import { usePrefersReducedMotion } from './motion'; @@ -27,12 +29,16 @@ export function Toast({ message, variant }: ToastProps) { const icon = variant === 'success' ? 'checkmark' : variant === 'error' ? 'close' : 'information'; return ( - + - + - + {message} @@ -60,28 +69,28 @@ const styles = StyleSheet.create({ right: 0, bottom: 0, left: 0, - zIndex: 40, + zIndex: DesignSystem.zIndex.toast, alignItems: 'center', - paddingHorizontal: 16, + paddingHorizontal: DesignSystem.spacing.l, + pointerEvents: 'none', }, toast: { width: '100%', - maxWidth: 440, - minHeight: 50, + maxWidth: LAYOUT.maxToastWidth, + minHeight: 48, borderWidth: 1, - borderRadius: 10, - paddingHorizontal: 10, - paddingVertical: 8, + borderRadius: DesignSystem.radii.medium, + paddingHorizontal: DesignSystem.spacing.m, + paddingVertical: DesignSystem.spacing.m, flexDirection: 'row', alignItems: 'center', - gap: 9, + gap: DesignSystem.spacing.m, }, icon: { - width: 30, - height: 30, - borderRadius: 7, + width: 32, + height: 32, + borderRadius: DesignSystem.radii.small, alignItems: 'center', justifyContent: 'center', }, - message: { flex: 1, fontSize: 13, lineHeight: 18, fontWeight: '500' }, }); diff --git a/src/constants/designSystem.ts b/src/constants/designSystem.ts index 2c344f2..cac30bd 100644 --- a/src/constants/designSystem.ts +++ b/src/constants/designSystem.ts @@ -1,16 +1,45 @@ +import type { TextStyle } from 'react-native'; + import { AppTheme } from './theme'; +import type { Theme } from './theme'; + +export type TypographyVariant = keyof typeof AppTheme.typography; + +/** + * Converts a typography token into a React Native text style. + * Tokens intentionally store `size`/`weight` so they stay platform agnostic; + * screens should consume this helper instead of spreading the raw token. + */ +export function textStyle(variant: TypographyVariant, color?: string): TextStyle { + const token = AppTheme.typography[variant]; + + return { + fontSize: token.size, + lineHeight: token.lineHeight, + fontWeight: token.weight as TextStyle['fontWeight'], + letterSpacing: token.letterSpacing, + ...(color ? { color } : {}), + }; +} export const DesignSystem = { spacing: AppTheme.spacing, typography: AppTheme.typography, + /** Preferred way to build text styles from the type scale. */ + text: textStyle, fonts: AppTheme.fonts, borderRadius: AppTheme.borderRadius, + radii: AppTheme.radii, iconSizes: AppTheme.iconSizes, layout: AppTheme.layout, motion: AppTheme.motion, state: AppTheme.stateLayer, + shadows: AppTheme.shadows, + elevation: AppTheme.elevation, components: AppTheme.components, zIndex: AppTheme.zIndex, + accessibility: AppTheme.accessibility, }; export type DesignSystemType = typeof DesignSystem; +export type { Theme }; diff --git a/src/constants/layout.ts b/src/constants/layout.ts new file mode 100644 index 0000000..99a7961 --- /dev/null +++ b/src/constants/layout.ts @@ -0,0 +1,75 @@ +/** + * Canonical layout metrics for the Keysoft mobile design system. + * + * Single source of truth shared by the theme tokens and `useResponsiveLayout`. + * Based on Material 3 window size classes, adapted to an Android-first password + * manager (compact phones, foldables, split view, tablets, landscape phones and + * the development web target). + */ +export const BREAKPOINTS = { + compact: 0, + medium: 600, + expanded: 840, + large: 1200, +} as const; + +export type WindowSizeClass = keyof typeof BREAKPOINTS; + +export const WINDOW_SIZE_CLASSES: readonly WindowSizeClass[] = [ + 'compact', + 'medium', + 'expanded', + 'large', +]; + +export interface WindowClassMetrics { + /** Number of columns for card/list grids. */ + columns: number; + /** Screen edge padding and section gutter. */ + gutter: number; + /** Maximum width of centered content. */ + contentMaxWidth: number; + /** Gap between grid items. */ + itemGap: number; + /** Minimum height of a top bar / header row. */ + headerHeight: number; +} + +export const WINDOW_METRICS: Record = { + compact: { columns: 1, gutter: 16, contentMaxWidth: Infinity, itemGap: 12, headerHeight: 56 }, + medium: { columns: 2, gutter: 24, contentMaxWidth: 720, itemGap: 16, headerHeight: 64 }, + expanded: { columns: 2, gutter: 32, contentMaxWidth: 960, itemGap: 20, headerHeight: 64 }, + large: { columns: 3, gutter: 32, contentMaxWidth: 1120, itemGap: 20, headerHeight: 72 }, +}; + +export const TOUCH_TARGET = { + /** Accessibility minimum from the platform guidelines. */ + minimum: 44, + /** Comfortable target used by primary controls. */ + comfortable: 48, + large: 56, +} as const; + +export const LAYOUT = { + maxReadableWidth: 720, + maxFormWidth: 560, + maxAuthWidth: 480, + maxConfirmationWidth: 420, + maxToastWidth: 480, + listItemMinHeight: 56, + hairline: 1, +} as const; + +export const DIALOG_MAX_WIDTH: Record = { + compact: 420, + medium: 480, + expanded: 560, + large: 600, +}; + +export const MODAL_MAX_WIDTH: Record = { + compact: 560, + medium: 600, + expanded: 640, + large: 680, +}; diff --git a/src/constants/theme.ts b/src/constants/theme.ts index db88099..15eb6b4 100644 --- a/src/constants/theme.ts +++ b/src/constants/theme.ts @@ -1,4 +1,5 @@ -import { Platform } from 'react-native'; +import { Easing, Platform } from 'react-native'; +import { BREAKPOINTS, LAYOUT, TOUCH_TARGET, WINDOW_METRICS } from './layout'; // === Palette ============================================================== // Nocturne: one quiet blue accent over cool neutral surfaces. @@ -33,22 +34,22 @@ const palette = { // Neutrals (Slate - Cool Gray with Blue undertones) slate: { 25: '#fcfcfc', - 50: '#f8fafc', // Light Bg - 100: '#f1f5f9', // Light Surface - 200: '#e2e8f0', // Light Border + 50: '#f8fafc', + 100: '#f1f5f9', + 200: '#e2e8f0', 300: '#cbd5e1', - 400: '#94a3b8', // Dark Text Secondary - 500: '#64748b', // Light Text Secondary + 400: '#94a3b8', + 500: '#64748b', 600: '#475569', - 700: '#334155', // Dark Border - 800: '#1e293b', // Dark Surface (Card) - 900: '#0f172a', // Dark Bg - 950: '#020617', // Deep Dark + 700: '#334155', + 800: '#1e293b', + 900: '#0f172a', + 950: '#020617', }, - success: '#10b981', // Emerald 500 - error: '#ef4444', // Red 500 - warning: '#f59e0b', // Amber 500 - info: '#3b82f6', // Blue 500 + success: '#10b981', + error: '#ef4444', + warning: '#f59e0b', + info: '#3b82f6', }; const baseFonts = { @@ -56,11 +57,11 @@ const baseFonts = { secure: Platform.select({ ios: 'Courier', android: 'monospace', default: 'Courier' }), sizes: { xsmall: 11, - small: 13, - medium: 15, - large: 17, + small: 12, + medium: 14, + large: 16, xlarge: 20, - xxlarge: 24, + xxlarge: 26, display: 32, }, weights: { @@ -71,84 +72,91 @@ const baseFonts = { }, }; +/** + * Type scale. `lineHeight` values are aligned to the 4pt grid and `letterSpacing` + * is negative for display sizes and slightly positive for small labels. + */ const typography = { - display: { size: 34, lineHeight: 40, weight: '700' }, - headline: { size: 26, lineHeight: 32, weight: '600' }, - title: { size: 20, lineHeight: 28, weight: '600' }, - subtitle: { size: 18, lineHeight: 24, weight: '500' }, - body: { size: 16, lineHeight: 24, weight: '400' }, - label: { size: 14, lineHeight: 20, weight: '500' }, - caption: { size: 12, lineHeight: 16, weight: '500' }, - micro: { size: 11, lineHeight: 14, weight: '500' }, + display: { size: 32, lineHeight: 40, weight: '700', letterSpacing: -0.6 }, + headline: { size: 26, lineHeight: 32, weight: '600', letterSpacing: -0.4 }, + title: { size: 20, lineHeight: 28, weight: '600', letterSpacing: -0.2 }, + titleLarge: { size: 22, lineHeight: 28, weight: '600', letterSpacing: -0.3 }, + subtitle: { size: 18, lineHeight: 24, weight: '600', letterSpacing: -0.1 }, + body: { size: 16, lineHeight: 24, weight: '400', letterSpacing: 0 }, + bodySmall: { size: 14, lineHeight: 20, weight: '400', letterSpacing: 0 }, + label: { size: 14, lineHeight: 20, weight: '500', letterSpacing: 0.1 }, + labelSmall: { size: 12, lineHeight: 16, weight: '500', letterSpacing: 0.2 }, + caption: { size: 12, lineHeight: 16, weight: '400', letterSpacing: 0.2 }, + micro: { size: 11, lineHeight: 14, weight: '500', letterSpacing: 0.3 }, + button: { size: 15, lineHeight: 20, weight: '600', letterSpacing: 0.1 }, }; +/** 4pt spacing grid. Legacy names are preserved for existing screens. */ const spacing = { + none: 0, + xxs: 2, xs: 4, s: 8, m: 12, - l: 20, - xl: 28, - xxl: 40, - xxxl: 56, + l: 16, + xl: 24, + xxl: 32, + xxxl: 48, + huge: 64, }; const borderRadius = { - small: 4, - medium: 8, - large: 14, + none: 0, + xs: 6, + small: 8, + medium: 12, + large: 16, + xl: 20, + xxl: 28, pill: 999, }; const iconSizes = { xs: 16, - sm: 20, - md: 24, - lg: 32, - xl: 48, + sm: 18, + md: 22, + lg: 28, + xl: 36, }; const layout = { - contentMaxWidth: 1180, - horizontalGutter: 24, - sectionGap: 32, - cardGap: 16, breakpoints: { - phone: 0, - tablet: 768, - tv: 1280, - }, - maxWidths: { - phone: Infinity, - tablet: 1180, - tv: 1180, - }, - gridColumns: { - phone: 1, - tablet: 2, - tv: 2, - }, - horizontalPadding: { - phone: 16, - tablet: 32, - tv: 32, - }, - itemGap: { - phone: 16, - tablet: 24, - tv: 24, + ...BREAKPOINTS, + // Legacy aliases kept for callers that still speak in device categories. + phone: BREAKPOINTS.compact, + tablet: BREAKPOINTS.medium, + tv: BREAKPOINTS.large, }, + windowMetrics: WINDOW_METRICS, + contentMaxWidth: WINDOW_METRICS.large.contentMaxWidth, + horizontalGutter: WINDOW_METRICS.compact.gutter, + sectionGap: spacing.xxl, + cardGap: spacing.m, + maxReadableWidth: LAYOUT.maxReadableWidth, + maxFormWidth: LAYOUT.maxFormWidth, + maxAuthWidth: LAYOUT.maxAuthWidth, + listItemMinHeight: LAYOUT.listItemMinHeight, + touchTarget: TOUCH_TARGET, }; const motion = { durations: { - shortest: 150, - short: 200, - medium: 300, - long: 400, + instant: 0, + fastest: 80, + fast: 120, + short: 160, + medium: 220, + long: 320, }, easing: { - standard: 'cubic-bezier(0.4, 0.0, 0.2, 1)', // Material Standard - emphasized: 'cubic-bezier(0.05, 0.7, 0.1, 1.0)', // Snappy + standard: Easing.bezier(0.2, 0, 0, 1), + emphasized: Easing.bezier(0.05, 0.7, 0.1, 1), + decelerate: Easing.out(Easing.cubic), }, }; @@ -160,10 +168,13 @@ const stateLayer = { disabled: 0.38, }; +/** Minimum interactive sizes; compact controls must still reach 44pt. */ const componentHeights = { - button: { sm: 40, md: 48, lg: 56 }, // Standardized touch targets - input: { sm: 40, md: 48, lg: 56 }, - chip: 32, + button: { sm: TOUCH_TARGET.minimum, md: TOUCH_TARGET.comfortable, lg: TOUCH_TARGET.large }, + input: { md: TOUCH_TARGET.comfortable, lg: TOUCH_TARGET.large }, + iconButton: TOUCH_TARGET.comfortable, + listItem: LAYOUT.listItemMinHeight, + chip: 36, topBar: 64, }; @@ -174,6 +185,15 @@ const zIndex = { toast: 40, }; +const accessibility = { + minimumTouchTarget: TOUCH_TARGET.minimum, + comfortableTouchTarget: TOUCH_TARGET.comfortable, + normalTextContrast: 4.5, + largeTextContrast: 3, + /** Cap used for dense chrome where unbounded font scaling breaks layout. */ + maxFontSizeMultiplier: 1.35, +}; + // === Surfaces Definition (The Core of the Look) =========================== const darkSurfaces = { @@ -202,17 +222,17 @@ const gradientsLight = { accent: ['#2563eb', '#60a5fa'], }; -// Modern Shadows (Softer, diffused) +// Modern Shadows (softer and more diffused than the previous elevation set) const darkShadows = Platform.OS === 'web' ? { - small: { boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.3)', elevation: 2 }, + small: { boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.28)', elevation: 2 }, medium: { - boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.15)', + boxShadow: '0 6px 16px -4px rgba(0, 0, 0, 0.36), 0 2px 6px -2px rgba(0, 0, 0, 0.2)', elevation: 6, }, large: { - boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.15)', + boxShadow: '0 16px 32px -8px rgba(0, 0, 0, 0.42), 0 6px 12px -4px rgba(0, 0, 0, 0.24)', elevation: 12, }, } @@ -220,22 +240,22 @@ const darkShadows = small: { shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.3, + shadowOpacity: 0.28, shadowRadius: 2, elevation: 2, }, medium: { shadowColor: '#000', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.3, - shadowRadius: 6, + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.34, + shadowRadius: 16, elevation: 6, }, large: { shadowColor: '#000', - shadowOffset: { width: 0, height: 10 }, - shadowOpacity: 0.3, - shadowRadius: 12, + shadowOffset: { width: 0, height: 16 }, + shadowOpacity: 0.42, + shadowRadius: 32, elevation: 12, }, }; @@ -243,36 +263,38 @@ const darkShadows = const lightShadows = Platform.OS === 'web' ? { - small: { boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)', elevation: 1 }, + small: { boxShadow: '0 1px 2px 0 rgba(15, 23, 42, 0.06)', elevation: 1 }, medium: { - boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)', + boxShadow: + '0 6px 16px -4px rgba(15, 23, 42, 0.12), 0 2px 6px -2px rgba(15, 23, 42, 0.06)', elevation: 3, }, large: { - boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)', + boxShadow: + '0 16px 32px -8px rgba(15, 23, 42, 0.16), 0 6px 12px -4px rgba(15, 23, 42, 0.08)', elevation: 10, }, } : { small: { - shadowColor: palette.slate[500], + shadowColor: palette.slate[700], shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.05, + shadowOpacity: 0.06, shadowRadius: 2, elevation: 1, }, medium: { - shadowColor: palette.slate[600], - shadowOffset: { width: 0, height: 3 }, - shadowOpacity: 0.1, - shadowRadius: 6, + shadowColor: palette.slate[700], + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.12, + shadowRadius: 16, elevation: 3, }, large: { shadowColor: palette.slate[700], - shadowOffset: { width: 0, height: 10 }, - shadowOpacity: 0.1, - shadowRadius: 15, + shadowOffset: { width: 0, height: 16 }, + shadowOpacity: 0.16, + shadowRadius: 32, elevation: 10, }, }; @@ -282,40 +304,54 @@ const createTheme = (mode: 'dark' | 'light') => { const surfaces = isDark ? darkSurfaces : lightSurfaces; const gradients = isDark ? gradientsDark : gradientsLight; const shadows = isDark ? darkShadows : lightShadows; + const onPrimary = isDark ? '#0b1220' : '#ffffff'; const colors = { // Brand - primary: isDark ? palette.primary[400] : palette.primary[600], // Lighter in dark mode for contrast + primary: isDark ? palette.primary[400] : palette.primary[600], primaryLight: isDark ? palette.primary[300] : palette.primary[400], primaryDark: isDark ? palette.primary[500] : palette.primary[700], + onPrimary, + primaryContainer: isDark ? 'rgba(96, 165, 250, 0.16)' : '#dfe9fd', + onPrimaryContainer: isDark ? '#dbeafe' : '#172554', accent: isDark ? palette.accent[400] : palette.accent[600], accentLight: isDark ? palette.accent[300] : palette.accent[500], accentDark: isDark ? palette.accent[500] : palette.accent[700], // Backgrounds - background: surfaces.level0, // Main App Background + background: surfaces.level0, backgroundMuted: isDark ? '#202230' : '#eceef4', backgroundLight: isDark ? '#232532' : '#ffffff', - backgroundElevated: surfaces.level1, // Cards + backgroundElevated: surfaces.level1, + backgroundSunken: isDark ? '#12141f' : '#eceef4', + surfaceMuted: isDark ? '#1c1f30' : '#f0f1f6', // Typography text: isDark ? '#e9e9ed' : '#171925', textSecondary: isDark ? '#a3a5b2' : '#5d6070', - textTertiary: isDark ? '#737684' : '#7a7e8e', + textTertiary: isDark ? '#8b8ea0' : '#6a6e7d', + textDisabled: isDark ? '#6b6e7d' : '#9a9eab', textLight: '#ffffff', textDark: palette.slate[900], - // Status - error: palette.error, - success: palette.success, - warning: palette.warning, - info: palette.info, + // Status (mode-specific so text stays readable on both themes) + error: isDark ? '#f87171' : '#d21f1f', + success: isDark ? '#34d399' : '#047857', + warning: isDark ? '#fbbf24' : '#b45309', + info: isDark ? '#60a5fa' : '#1d4ed8', + errorSoft: isDark ? 'rgba(248, 113, 113, 0.14)' : 'rgba(210, 31, 31, 0.1)', + successSoft: isDark ? 'rgba(52, 211, 153, 0.14)' : 'rgba(4, 120, 87, 0.1)', + warningSoft: isDark ? 'rgba(251, 191, 36, 0.16)' : 'rgba(180, 83, 9, 0.1)', + infoSoft: isDark ? 'rgba(96, 165, 250, 0.14)' : 'rgba(29, 78, 216, 0.08)', + focusRing: isDark ? '#93c5fd' : '#2563eb', // Components card: surfaces.level1, border: isDark ? '#3b3e4b' : '#d7d9e2', + borderStrong: isDark ? '#4b4f60' : '#b9bdca', divider: isDark ? '#30333f' : '#e1e2e8', + skeleton: isDark ? '#262938' : '#e6e8ef', // Inputs inputBackground: isDark ? '#232532' : '#ffffff', @@ -345,7 +381,7 @@ const createTheme = (mode: 'dark' | 'light') => { chipBorder: isDark ? 'rgba(96, 165, 250, 0.45)' : '#bfdbfe', chipText: isDark ? '#dbeafe' : '#1e40af', chipSelectedBackground: palette.primary[500], - chipSelectedText: '#ffffff', + chipSelectedText: onPrimary, // Lists listItemBackground: isDark ? '#1c1f30' : '#ffffff', @@ -356,6 +392,8 @@ const createTheme = (mode: 'dark' | 'light') => { }; return { + mode, + isDark, colors, surfaces, gradients, @@ -363,11 +401,14 @@ const createTheme = (mode: 'dark' | 'light') => { typography, spacing, borderRadius, + radii: borderRadius, shadows, + elevation: shadows, layout, motion, iconSizes, stateLayer, + accessibility, components: { heights: componentHeights, focusRing: `0 0 0 3px ${palette.primary[500]}40`, @@ -381,3 +422,4 @@ export const LightTheme = createTheme('light'); export const AppTheme = DarkTheme; // Default export export type Theme = typeof AppTheme; +export { palette }; diff --git a/src/hooks/settings/useExportImport.ts b/src/hooks/settings/useExportImport.ts index e5a5a4b..7dc67d6 100644 --- a/src/hooks/settings/useExportImport.ts +++ b/src/hooks/settings/useExportImport.ts @@ -41,6 +41,10 @@ export const useExportImport = ({ const [importFileContent, setImportFileContent] = useState(''); const [showImportPasswordDialog, setShowImportPasswordDialog] = useState(false); + // Blocking loader for the multi-second KDF + file work. The screens render a modal + // Dialog from this state so the user sees progress instead of a frozen screen. + const [busyOperation, setBusyOperation] = useState<'export' | 'import' | null>(null); + const showImportPasswordError = useCallback( (message: string) => { setShowImportPasswordDialog(false); @@ -152,6 +156,8 @@ export const useExportImport = ({ return; } + setBusyOperation('export'); + const passwords = await Storage.getAllPasswords(); const notes = await Storage.getNotes(); @@ -183,25 +189,18 @@ export const useExportImport = ({ } temporaryFileUri = `${exportDirectory}${filePrefix}${new Date().toISOString().split('T')[0]}${fileExtension}`; - alert(t('export_in_progress'), t('export_preparing_file'), [ - { - text: t('cancel'), - onPress: () => { - return; - }, - style: 'cancel', - }, - ]); - await FileSystem.writeAsStringAsync(temporaryFileUri, jsonData); const isAvailable = await Sharing.isAvailableAsync(); if (!isAvailable) { + setBusyOperation(null); alert(t('error'), t('sharing_not_available')); return; } + setBusyOperation(null); + const result = await new Promise((resolve) => { alert( t('file_ready'), @@ -257,9 +256,11 @@ export const useExportImport = ({ Logger.info("Esportazione non completata o annullata dall'utente"); } } catch (error) { + setBusyOperation(null); Logger.error("Errore durante l'esportazione:", error); alert(t('error'), t('export_error')); } finally { + setBusyOperation(null); setExportPassword(''); if (temporaryFileUri) { try { @@ -358,19 +359,26 @@ export const useExportImport = ({ const parsedData = JSON.parse(importFileContent); + // Close the password sheet before the blocking loader appears: a Dialog must not + // compete with the sheet's own native modal layer. + setShowImportPasswordDialog(false); + setBusyOperation('import'); + try { const decryptedData = await Crypto.decryptWithPassword(parsedData.data, importPassword); const importData = JSON.parse(decryptedData); setImportPassword(''); setImportFileContent(''); - setShowImportPasswordDialog(false); + setBusyOperation(null); await processImport(importData); } catch (_error) { + setBusyOperation(null); showImportPasswordError(t('invalid_decryption_password')); } } catch (_error) { + setBusyOperation(null); showImportPasswordError(t('import_error')); } }, [importPassword, importFileContent, t, processImport, showImportPasswordError]); @@ -385,6 +393,7 @@ export const useExportImport = ({ setShowExportDialog, handleExportPasswords, performExport, + busyOperation, // Import isEncryptedImport, setIsEncryptedImport, diff --git a/src/locales/en.ts b/src/locales/en.ts index 36f403d..0ba8e08 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -329,6 +329,7 @@ export const en = { 'You are trying to import too many passwords. Reduce the number of passwords to import or delete some existing passwords.', no_data: 'No data', no_data_to_export: 'There are no passwords or notes to export.', + import_in_progress: 'Import in progress', import_success: 'Import completed', import_success_message_all: 'Successfully imported {passwords} passwords and {notes} notes.', import_success_message_passwords: 'Successfully imported {count} passwords.', diff --git a/src/locales/it.ts b/src/locales/it.ts index 18cc888..c0fffcc 100644 --- a/src/locales/it.ts +++ b/src/locales/it.ts @@ -341,6 +341,7 @@ export const it = { 'Stai tentando di importare troppe password. Riduci il numero di password da importare o elimina alcune password esistenti.', no_data: 'Nessun dato', no_data_to_export: 'Non ci sono password o note da esportare.', + import_in_progress: 'Importazione in corso', import_success: 'Importazione completata', import_success_message_all: 'Sono state importate con successo {passwords} password e {notes} note.', diff --git a/src/navigation/TabNavigator.tsx b/src/navigation/TabNavigator.tsx index 4943f40..91f22a5 100644 --- a/src/navigation/TabNavigator.tsx +++ b/src/navigation/TabNavigator.tsx @@ -6,6 +6,7 @@ import { Ionicons } from '@expo/vector-icons'; import { useTheme } from '../contexts/ThemeContext'; import { useResponsiveLayout } from '../utils/responsive'; import { useLanguage } from '../contexts/LanguageContext'; +import { DesignSystem } from '../constants/designSystem'; import type { RootStackParamList } from './index'; import { MotionPressable } from '../components/ui/motion'; @@ -28,6 +29,9 @@ export type TabParamList = { const Tab = createBottomTabNavigator(); +// Text-heavy and form tabs keep a readable line length on large windows. +const READABLE_CONTENT = { maxWidth: DesignSystem.layout.maxReadableWidth }; + // Screens wrapped with ScreenWrapper; components use hooks for navigation and route data const WrappedHomeScreen: React.FC = () => ( @@ -36,19 +40,19 @@ const WrappedHomeScreen: React.FC = () => ( ); const WrappedPasswordGeneratorScreen: React.FC = () => ( - + ); const WrappedNotesScreen: React.FC = () => ( - + ); const WrappedSettingsScreen: React.FC = () => ( - + ); @@ -138,7 +142,7 @@ const CustomTabBar: React.FC = ({ state, navigation }) => { style={[ styles.tabBarContainer, { - paddingBottom: Math.max(insets.bottom, 8), + paddingBottom: Math.max(insets.bottom, DesignSystem.spacing.s), backgroundColor: theme.colors.background, borderTopColor: theme.colors.divider, }, @@ -167,7 +171,11 @@ const CustomTabBar: React.FC = ({ state, navigation }) => { style={[styles.tabItem, styles.addSlot]} > - + ) : null} @@ -181,7 +189,7 @@ const CustomTabBar: React.FC = ({ state, navigation }) => { > {isFocused ? ( @@ -223,25 +231,25 @@ const styles = StyleSheet.create({ flexDirection: 'row', minHeight: 56, width: '100%', - paddingHorizontal: 8, + paddingHorizontal: DesignSystem.spacing.s, alignItems: 'center', }, tabItem: { flex: 1, minWidth: 52, - minHeight: 52, + minHeight: 56, alignItems: 'center', justifyContent: 'center', - paddingVertical: 4, + paddingVertical: DesignSystem.spacing.xs, position: 'relative', }, addSlot: { - paddingTop: 3, + paddingTop: DesignSystem.spacing.xxs, }, addButton: { - width: 36, - height: 36, - borderRadius: 18, + width: 40, + height: 40, + borderRadius: DesignSystem.radii.pill, justifyContent: 'center', alignItems: 'center', borderWidth: 1, @@ -251,7 +259,7 @@ const styles = StyleSheet.create({ top: 0, width: 24, height: 2, - borderRadius: 1, + borderRadius: DesignSystem.radii.pill, }, }); diff --git a/src/navigation/index.tsx b/src/navigation/index.tsx index 4b36c8d..ea333bb 100644 --- a/src/navigation/index.tsx +++ b/src/navigation/index.tsx @@ -22,6 +22,7 @@ import TabNavigator from './TabNavigator'; // Auth Context import { useAuth } from '../contexts/AuthContext'; import { useTheme } from '../contexts/ThemeContext'; +import { DesignSystem } from '../constants/designSystem'; // Services import { Storage } from '../services'; @@ -31,6 +32,9 @@ import ScreenCaptureService from '../services/utils/screenCaptureService'; // Screen Wrapper import ScreenWrapper from '../components/ScreenWrapper'; +// Text-heavy and form screens keep a readable line length on large windows. +const READABLE_CONTENT = { maxWidth: DesignSystem.layout.maxReadableWidth }; + // Define navigation parameter types export type RootStackParamList = { Onboarding: undefined; @@ -55,54 +59,54 @@ type StackScreenComponent = React.FC >; const WrappedOnboardingScreen: StackScreenComponent<'Onboarding'> = () => ( - + ); const WrappedAuthScreen: StackScreenComponent<'Auth'> = () => ( - + ); // Screen components use useNavigation/useRoute internally, so // Stack props do not need to be passed; the wrapper ignores them. const WrappedPasswordDetailScreen: StackScreenComponent<'PasswordDetail'> = () => ( - + ); const WrappedPasswordGeneratorScreen: StackScreenComponent<'PasswordGenerator'> = () => ( - + ); const WrappedSettingsScreen: StackScreenComponent<'Settings'> = () => ( - + ); const WrappedPrivacyPolicyScreen: StackScreenComponent<'PrivacyPolicy'> = ({ navigation }) => ( - + ); const WrappedOpenSourceScreen: StackScreenComponent<'OpenSource'> = ({ navigation }) => ( - + ); const WrappedNotesScreen: StackScreenComponent<'Notes'> = () => ( - + ); const WrappedNoteDetailScreen: StackScreenComponent<'NoteDetail'> = () => ( - + ); const WrappedVaultHealthScreen: StackScreenComponent<'VaultHealth'> = () => ( - + ); diff --git a/src/screens/AuthScreen.tsx b/src/screens/AuthScreen.tsx index fe1dedb..97ac201 100644 --- a/src/screens/AuthScreen.tsx +++ b/src/screens/AuthScreen.tsx @@ -15,6 +15,7 @@ import { UserPreferences } from '../models/User'; import Logger from '../utils/logger'; import { MotionPressable, Reveal } from '../components/ui/motion'; import { PinKeypad } from '../components/ui/pin-keypad'; +import { DesignSystem } from '../constants/designSystem'; import { useResponsiveLayout } from '../utils/responsive'; import { ProfileAvatar } from '../components/ProfileAvatar'; @@ -244,7 +245,10 @@ const AuthScreen: React.FC = () => { }; const username = userPreferences?.username || t('user'); - const contentWidth = Math.min(layout.width - layout.horizontalPadding * 2, 480); + const avatarSize = layout.isShortLandscape ? 52 : 64; + const contentPaddingTop = layout.isShortLandscape + ? DesignSystem.spacing.l + : DesignSystem.spacing.xl; return ( { edges={['top', 'bottom']} > - + @@ -358,7 +371,7 @@ const AuthScreen: React.FC = () => { {isLoading ? ( - + ) : null} @@ -425,51 +438,60 @@ function waitForNextFrame(): Promise { } const styles = StyleSheet.create({ - safeArea: { flex: 1, alignItems: 'center' }, + safeArea: { flex: 1 }, authScroll: { flexGrow: 1, alignItems: 'center' }, content: { flex: 1, - minHeight: 620, - paddingHorizontal: 20, - paddingTop: 72, - paddingBottom: 18, - justifyContent: 'space-between', + width: '100%', + alignSelf: 'center', + paddingBottom: DesignSystem.spacing.l, + justifyContent: 'center', + gap: DesignSystem.spacing.xxl, }, identity: { alignItems: 'center' }, - avatarSpacing: { marginBottom: 22 }, + avatarSpacing: { marginBottom: DesignSystem.spacing.xl }, title: { - fontSize: 22, - lineHeight: 28, - fontWeight: '600', - letterSpacing: -0.3, + ...DesignSystem.text('headline'), + textAlign: 'center', + }, + subtitle: { + ...DesignSystem.text('bodySmall'), textAlign: 'center', + marginTop: DesignSystem.spacing.s, }, - subtitle: { fontSize: 13, lineHeight: 18, textAlign: 'center', marginTop: 7 }, - pinArea: { width: '100%', maxWidth: 320, alignSelf: 'center' }, + pinArea: { width: '100%', maxWidth: 340, alignSelf: 'center' }, pinDots: { flexDirection: 'row', justifyContent: 'center', - gap: 12, - marginBottom: 68, + gap: DesignSystem.spacing.m, + marginBottom: DesignSystem.spacing.xl, + }, + pinDot: { + width: 10, + height: 10, + borderRadius: DesignSystem.radii.pill, + borderWidth: 1, }, - pinDot: { width: 10, height: 10, borderRadius: 5, borderWidth: 1 }, errorText: { + ...DesignSystem.text('caption'), position: 'absolute', - top: 28, + top: DesignSystem.spacing.xxl, left: 0, right: 0, - fontSize: 12, - lineHeight: 16, textAlign: 'center', }, testInput: { position: 'absolute', width: 1, height: 1, opacity: 0 }, testLoginButton: { position: 'absolute', width: 1, height: 1, opacity: 0 }, testBiometricButton: { position: 'absolute', width: 1, height: 1, opacity: 0 }, - forgotButton: { minHeight: 44, alignItems: 'center', justifyContent: 'center' }, - forgotText: { fontSize: 12, lineHeight: 16 }, + forgotButton: { + minHeight: DesignSystem.layout.touchTarget.minimum, + alignItems: 'center', + justifyContent: 'center', + }, + forgotText: { ...DesignSystem.text('caption') }, loadingIndicator: { position: 'absolute', - top: 26, + top: DesignSystem.spacing.xl, alignSelf: 'center', }, }); diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index f38d2b5..50dd533 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -17,6 +17,7 @@ import NotificationBell from '../components/NotificationBell'; import PasswordCard from '../components/PasswordCard'; import { MotionPressable, Reveal } from '../components/ui/motion'; import { getAdaptiveCategories } from '../constants/categories'; +import { DesignSystem } from '../constants/designSystem'; import { useLanguage } from '../contexts/LanguageContext'; import { useTheme } from '../contexts/ThemeContext'; import { useHomeLogic } from '../hooks/useHomeLogic'; @@ -77,19 +78,20 @@ const HomeScreen: React.FC = () => { : categoryTotalPasswords === null ? passwords.length : categoryTotalPasswords; - const contentWidth = Math.min(layout.width, 920); const username = userPreferences?.username || t('user'); const renderPasswordItem = useCallback( ({ item }: { item: Password }) => ( - handleDeletePassword(item.id)} - onEdit={() => navigate('PasswordDetail', { passwordId: item.id, mode: 'edit' })} - onPress={() => navigate('PasswordDetail', { passwordId: item.id, mode: 'view' })} - /> + + handleDeletePassword(item.id)} + onEdit={() => navigate('PasswordDetail', { passwordId: item.id, mode: 'edit' })} + onPress={() => navigate('PasswordDetail', { passwordId: item.id, mode: 'view' })} + /> + ), - [handleDeletePassword, navigate], + [handleDeletePassword, layout.columns, navigate], ); return ( @@ -98,7 +100,7 @@ const HomeScreen: React.FC = () => { edges={['top']} > - + {t('tab_vault')} @@ -122,7 +124,11 @@ const HomeScreen: React.FC = () => { }, ]} > - + { onPress={() => setSearchQuery('')} style={styles.searchClear} > - + ) : null} @@ -159,7 +169,11 @@ const HomeScreen: React.FC = () => { }, ]} > - + {t('vault_health_title')} @@ -171,12 +185,11 @@ const HomeScreen: React.FC = () => { {t('vault_health_local_only')} - - - - + @@ -190,7 +203,11 @@ const HomeScreen: React.FC = () => { onPress={handleRefresh} style={styles.retryButton} > - + ) : null} @@ -208,6 +225,7 @@ const HomeScreen: React.FC = () => { accessibilityLabel={category.name} accessibilityRole="button" accessibilityState={{ selected: isSelected }} + hitSlop={4} key={category.id} onPress={() => handleFilterByCategory(category.id)} style={[ @@ -227,7 +245,7 @@ const HomeScreen: React.FC = () => { @@ -259,7 +277,11 @@ const HomeScreen: React.FC = () => { ) : passwords.length === 0 ? ( - + {searchQuery ? t('no_results') : t('no_passwords_saved')} @@ -270,10 +292,12 @@ const HomeScreen: React.FC = () => { ) : ( 1 ? styles.columnWrapper : undefined} - contentContainerStyle={styles.listContent} + columnWrapperStyle={ + layout.columns > 1 ? [styles.columnWrapper, { gap: layout.gridGap }] : undefined + } + contentContainerStyle={[styles.listContent, { gap: layout.gridGap }]} data={passwords} - initialNumToRender={10} + initialNumToRender={12} key={`vault-${layout.columns}`} keyExtractor={(item: Password) => item.id} ListFooterComponent={ @@ -303,100 +327,127 @@ const HomeScreen: React.FC = () => { }; const styles = StyleSheet.create({ - safeArea: { flex: 1, alignItems: 'center' }, - content: { flex: 1, paddingHorizontal: 14 }, + safeArea: { flex: 1 }, + content: { flex: 1 }, header: { - minHeight: 58, + minHeight: 56, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', + gap: DesignSystem.spacing.s, }, - headerTitle: { fontSize: 23, lineHeight: 29, fontWeight: '600', letterSpacing: -0.35 }, - headerActions: { flexDirection: 'row', alignItems: 'center', gap: 6 }, + headerTitle: { ...DesignSystem.text('titleLarge'), flexShrink: 1 }, + headerActions: { flexDirection: 'row', alignItems: 'center', gap: DesignSystem.spacing.s }, search: { - minHeight: 42, + minHeight: DesignSystem.layout.touchTarget.comfortable, borderWidth: 1, - borderRadius: 8, + borderRadius: DesignSystem.radii.medium, flexDirection: 'row', alignItems: 'center', - paddingLeft: 10, - marginBottom: 8, + paddingLeft: DesignSystem.spacing.m, + marginBottom: DesignSystem.spacing.s, + }, + searchInput: { + ...DesignSystem.text('bodySmall'), + flex: 1, + minHeight: DesignSystem.layout.touchTarget.comfortable, + paddingHorizontal: DesignSystem.spacing.s, + paddingVertical: DesignSystem.spacing.s, + }, + searchClear: { + width: DesignSystem.layout.touchTarget.comfortable, + height: DesignSystem.layout.touchTarget.comfortable, + alignItems: 'center', + justifyContent: 'center', }, - searchInput: { flex: 1, minHeight: 40, paddingHorizontal: 8, paddingVertical: 8, fontSize: 13 }, - searchClear: { width: 40, height: 40, alignItems: 'center', justifyContent: 'center' }, healthStrip: { - minHeight: 60, + minHeight: 68, borderWidth: StyleSheet.hairlineWidth, - borderRadius: 8, - paddingHorizontal: 10, + borderRadius: DesignSystem.radii.medium, + paddingHorizontal: DesignSystem.spacing.m, flexDirection: 'row', alignItems: 'center', overflow: 'visible', }, - healthCopy: { flex: 1, marginHorizontal: 9 }, - healthTitle: { fontSize: 12, lineHeight: 16, fontWeight: '600' }, - healthBody: { fontSize: 10, lineHeight: 14, marginTop: 1 }, - healthProgress: { position: 'absolute', height: 2, left: 10, right: 10, bottom: 5 }, - healthProgressFill: { width: '78%', height: 2 }, + healthCopy: { flex: 1, marginHorizontal: DesignSystem.spacing.s }, + healthTitle: { ...DesignSystem.text('labelSmall'), fontWeight: '600' }, + healthBody: { ...DesignSystem.text('caption'), marginTop: DesignSystem.spacing.xxs }, errorBanner: { - minHeight: 42, - borderRadius: 8, - marginTop: 8, + minHeight: DesignSystem.layout.touchTarget.comfortable, + borderRadius: DesignSystem.radii.medium, + marginTop: DesignSystem.spacing.s, flexDirection: 'row', alignItems: 'center', - paddingLeft: 12, + paddingLeft: DesignSystem.spacing.m, + }, + errorText: { ...DesignSystem.text('caption'), flex: 1 }, + retryButton: { + width: DesignSystem.layout.touchTarget.comfortable, + height: DesignSystem.layout.touchTarget.comfortable, + alignItems: 'center', + justifyContent: 'center', + }, + categories: { flexGrow: 0, marginTop: DesignSystem.spacing.s }, + categoriesContent: { + gap: DesignSystem.spacing.s, + paddingBottom: DesignSystem.spacing.xs, + paddingRight: DesignSystem.spacing.s, }, - errorText: { flex: 1, fontSize: 12 }, - retryButton: { width: 42, height: 42, alignItems: 'center', justifyContent: 'center' }, - categories: { flexGrow: 0, marginTop: 9 }, - categoriesContent: { gap: 6, paddingBottom: 4 }, category: { - minHeight: 32, + minHeight: 40, borderWidth: 1, - borderRadius: 5, - paddingHorizontal: 8, + borderRadius: DesignSystem.radii.small, + paddingHorizontal: DesignSystem.spacing.m, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', - gap: 6, + gap: DesignSystem.spacing.s, }, categoryIcon: { - width: 20, - height: 20, - borderRadius: 5, + width: 22, + height: 22, + borderRadius: DesignSystem.radii.xs, alignItems: 'center', justifyContent: 'center', }, - categoryText: { fontSize: 11, lineHeight: 15, fontWeight: '600' }, - listHeading: { minHeight: 34, justifyContent: 'center', paddingHorizontal: 2 }, + categoryText: { ...DesignSystem.text('labelSmall') }, + listHeading: { + minHeight: 36, + justifyContent: 'center', + paddingHorizontal: DesignSystem.spacing.xxs, + }, listLabel: { - fontSize: 9, - lineHeight: 13, + ...DesignSystem.text('micro'), fontWeight: '700', letterSpacing: 1.1, textTransform: 'uppercase', }, list: { flex: 1 }, - listContent: { paddingBottom: 12 }, - columnWrapper: { gap: 12 }, + listContent: { paddingBottom: DesignSystem.spacing.m }, + columnWrapper: {}, + gridItem: { flex: 1 }, centerState: { flex: 1, alignItems: 'center', justifyContent: 'center', - paddingHorizontal: 34, - paddingBottom: 50, + paddingHorizontal: DesignSystem.spacing.xxl, + paddingBottom: DesignSystem.spacing.huge, }, emptyIcon: { - width: 54, - height: 54, - borderRadius: 27, + width: 56, + height: 56, + borderRadius: DesignSystem.radii.xxl, alignItems: 'center', justifyContent: 'center', - marginBottom: 16, + marginBottom: DesignSystem.spacing.l, + }, + emptyTitle: { ...DesignSystem.text('subtitle'), textAlign: 'center' }, + emptyBody: { + ...DesignSystem.text('bodySmall'), + textAlign: 'center', + marginTop: DesignSystem.spacing.s, }, - emptyTitle: { fontSize: 17, lineHeight: 22, fontWeight: '600', textAlign: 'center' }, - emptyBody: { fontSize: 12, lineHeight: 18, textAlign: 'center', marginTop: 6 }, - loadingMore: { height: 52, alignItems: 'center', justifyContent: 'center' }, + loadingMore: { height: 56, alignItems: 'center', justifyContent: 'center' }, }); export default HomeScreen; diff --git a/src/screens/NoteDetailScreen.tsx b/src/screens/NoteDetailScreen.tsx index 69d6857..a87aae3 100644 --- a/src/screens/NoteDetailScreen.tsx +++ b/src/screens/NoteDetailScreen.tsx @@ -7,6 +7,7 @@ import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view import { SafeAreaView } from 'react-native-safe-area-context'; import { MotionPressable, Reveal } from '../components/ui/motion'; +import { DesignSystem } from '../constants/designSystem'; import { useAlert } from '../contexts/AlertContext'; import { useLanguage } from '../contexts/LanguageContext'; import { useTheme } from '../contexts/ThemeContext'; @@ -123,15 +124,19 @@ const NoteDetailScreen: React.FC = () => { [effectiveLanguage], ); const colors = isDarkMode ? NOTE_COLORS.dark : NOTE_COLORS.light; - const contentWidth = Math.min(layout.width - layout.horizontalPadding * 2, 680); return ( - - + + { > @@ -154,7 +159,11 @@ const NoteDetailScreen: React.FC = () => { style={styles.headerButton} testID="note-edit-button" > - + { style={styles.headerButton} testID="note-delete-button" > - + ) : ( @@ -238,6 +251,7 @@ const NoteDetailScreen: React.FC = () => { { backgroundColor: isPinned ? theme.colors.chipBackground : 'transparent', borderColor: isPinned ? theme.colors.primary : theme.colors.border, + minHeight: layout.minTouchTarget, }, ]} > @@ -258,7 +272,10 @@ const NoteDetailScreen: React.FC = () => { setShowColorPicker((value) => !value)} - style={[styles.editChip, { borderColor: theme.colors.border }]} + style={[ + styles.editChip, + { borderColor: theme.colors.border, minHeight: layout.minTouchTarget }, + ]} > { ]} > {selectedColorName === colorName ? ( - + ) : null} ))} @@ -341,88 +362,119 @@ const NoteDetailScreen: React.FC = () => { }; const styles = StyleSheet.create({ - safeArea: { flex: 1, alignItems: 'center' }, + safeArea: { flex: 1 }, content: { flex: 1 }, header: { - minHeight: 54, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', }, - headerButton: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, + headerButton: { + width: DesignSystem.layout.touchTarget.comfortable, + height: DesignSystem.layout.touchTarget.comfortable, + alignItems: 'center', + justifyContent: 'center', + }, headerActions: { flexDirection: 'row', marginLeft: 'auto' }, - headerTitle: { flex: 1, fontSize: 17, lineHeight: 22, fontWeight: '600', marginLeft: 4 }, - headerSave: { fontSize: 13, fontWeight: '600' }, - scrollContent: { paddingHorizontal: 4, paddingBottom: 34 }, + headerTitle: { + ...DesignSystem.text('subtitle'), + flex: 1, + marginLeft: DesignSystem.spacing.xs, + }, + headerSave: { + ...DesignSystem.text('labelSmall'), + fontWeight: '600', + }, + scrollContent: { + paddingBottom: DesignSystem.spacing.xxl, + }, pinnedRow: { flexDirection: 'row', alignItems: 'center', - gap: 7, - marginTop: 14, - marginBottom: 11, + gap: DesignSystem.spacing.s, + marginTop: DesignSystem.spacing.l, + marginBottom: DesignSystem.spacing.m, + }, + noteDot: { + width: 6, + height: 6, + borderRadius: DesignSystem.radii.pill, }, - noteDot: { width: 6, height: 6, borderRadius: 3 }, pinnedBadge: { flexDirection: 'row', alignItems: 'center', - gap: 4, - paddingHorizontal: 7, - paddingVertical: 3, - borderRadius: 4, + gap: DesignSystem.spacing.xs, + paddingHorizontal: DesignSystem.spacing.s, + paddingVertical: DesignSystem.spacing.xxs, + borderRadius: DesignSystem.radii.xs, }, - pinnedText: { fontSize: 9, lineHeight: 12, fontWeight: '600' }, - noteTitle: { - fontSize: 25, - lineHeight: 31, + pinnedText: { + ...DesignSystem.text('micro'), fontWeight: '600', - letterSpacing: -0.4, - marginBottom: 18, }, - noteBody: { fontSize: 14, lineHeight: 23 }, - noteDates: { fontSize: 9, lineHeight: 13, marginTop: 24 }, - editControls: { flexDirection: 'row', gap: 7, marginTop: 10, marginBottom: 14 }, + noteTitle: { + ...DesignSystem.text('headline'), + marginBottom: DesignSystem.spacing.l, + }, + noteBody: { + ...DesignSystem.text('bodySmall'), + }, + noteDates: { + ...DesignSystem.text('micro'), + marginTop: DesignSystem.spacing.xl, + }, + editControls: { + flexDirection: 'row', + gap: DesignSystem.spacing.s, + marginTop: DesignSystem.spacing.m, + marginBottom: DesignSystem.spacing.l, + }, editChip: { - minHeight: 36, borderWidth: 1, - borderRadius: 7, + borderRadius: DesignSystem.radii.small, flexDirection: 'row', alignItems: 'center', - gap: 6, - paddingHorizontal: 11, + gap: DesignSystem.spacing.s, + paddingHorizontal: DesignSystem.spacing.m, + }, + editChipText: { + ...DesignSystem.text('micro'), + fontWeight: '600', + }, + colorSwatch: { + width: 10, + height: 10, + borderRadius: DesignSystem.radii.pill, }, - editChipText: { fontSize: 11, fontWeight: '600' }, - colorSwatch: { width: 10, height: 10, borderRadius: 5 }, colorPicker: { - borderRadius: 8, - padding: 12, + borderRadius: DesignSystem.radii.small, + padding: DesignSystem.spacing.m, flexDirection: 'row', flexWrap: 'wrap', - gap: 10, - marginBottom: 14, + gap: DesignSystem.spacing.m, + marginBottom: DesignSystem.spacing.l, }, colorOption: { width: 34, height: 34, - borderRadius: 17, + borderRadius: DesignSystem.radii.pill, alignItems: 'center', justifyContent: 'center', }, titleInput: { - minHeight: 48, + ...DesignSystem.text('subtitle'), + minHeight: DesignSystem.layout.touchTarget.comfortable, borderWidth: 1, - borderRadius: 8, - paddingHorizontal: 12, - fontSize: 17, - fontWeight: '600', - marginBottom: 10, + borderRadius: DesignSystem.radii.small, + paddingHorizontal: DesignSystem.spacing.m, + marginBottom: DesignSystem.spacing.m, }, bodyInput: { + ...DesignSystem.text('bodySmall'), minHeight: 320, borderWidth: 1, - borderRadius: 8, - padding: 12, - fontSize: 14, - lineHeight: 22, + borderRadius: DesignSystem.radii.small, + padding: DesignSystem.spacing.m, }, }); diff --git a/src/screens/NotesScreen.tsx b/src/screens/NotesScreen.tsx index e916f13..dbdc4ef 100644 --- a/src/screens/NotesScreen.tsx +++ b/src/screens/NotesScreen.tsx @@ -14,6 +14,7 @@ import { StackNavigationProp } from '@react-navigation/stack'; import { SafeAreaView } from 'react-native-safe-area-context'; import { MotionPressable, Reveal } from '../components/ui/motion'; +import { DesignSystem } from '../constants/designSystem'; import { useAlert } from '../contexts/AlertContext'; import { useLanguage } from '../contexts/LanguageContext'; import { useTheme } from '../contexts/ThemeContext'; @@ -121,23 +122,33 @@ const NotesScreen: React.FC = () => { [dateFormatter, isDarkMode, navigation, theme], ); - const contentWidth = Math.min(layout.width - layout.horizontalPadding * 2, 680); - return ( - - + + {t('tab_notes')} navigation.navigate('NoteDetail', { mode: 'create' })} - style={[styles.addButton, { borderColor: theme.colors.primary }]} + style={[ + styles.addButton, + { + borderColor: theme.colors.primary, + width: layout.minTouchTarget, + height: layout.minTouchTarget, + }, + ]} > - + @@ -151,7 +162,11 @@ const NotesScreen: React.FC = () => { }, ]} > - + { setSearchQuery('')} - style={styles.clearButton} + style={[ + styles.clearButton, + { width: layout.minTouchTarget, height: layout.minTouchTarget }, + ]} > - + ) : null} @@ -183,7 +205,11 @@ const NotesScreen: React.FC = () => { ListEmptyComponent={ - + {searchQuery ? t('no_notes_found') : t('no_notes_yet')} @@ -218,78 +244,116 @@ const NotesScreen: React.FC = () => { }; const styles = StyleSheet.create({ - safeArea: { flex: 1, alignItems: 'center' }, + safeArea: { flex: 1 }, content: { flex: 1 }, header: { - minHeight: 58, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', }, - title: { fontSize: 23, lineHeight: 29, fontWeight: '600', letterSpacing: -0.35 }, + title: { + ...DesignSystem.text('headline'), + }, addButton: { - width: 38, - height: 38, - borderRadius: 8, + borderRadius: DesignSystem.radii.medium, borderWidth: 1, alignItems: 'center', justifyContent: 'center', }, search: { - minHeight: 42, + minHeight: DesignSystem.layout.touchTarget.comfortable, borderWidth: 1, - borderRadius: 8, + borderRadius: DesignSystem.radii.medium, flexDirection: 'row', alignItems: 'center', - paddingLeft: 10, - marginBottom: 8, + paddingLeft: DesignSystem.spacing.m, + marginBottom: DesignSystem.spacing.s, + }, + searchInput: { + ...DesignSystem.text('bodySmall'), + flex: 1, + minHeight: DesignSystem.layout.touchTarget.comfortable, + paddingHorizontal: DesignSystem.spacing.s, + paddingVertical: DesignSystem.spacing.s, + }, + clearButton: { + alignItems: 'center', + justifyContent: 'center', }, - searchInput: { flex: 1, minHeight: 40, paddingHorizontal: 8, paddingVertical: 8, fontSize: 13 }, - clearButton: { width: 40, height: 40, alignItems: 'center', justifyContent: 'center' }, - listContent: { paddingBottom: 20 }, + listContent: { paddingBottom: DesignSystem.spacing.xl }, emptyListContent: { flexGrow: 1 }, sectionLabel: { - fontSize: 9, - lineHeight: 13, + ...DesignSystem.text('micro'), fontWeight: '700', - letterSpacing: 1.1, textTransform: 'uppercase', - marginTop: 9, - marginBottom: 2, + marginTop: DesignSystem.spacing.s, + marginBottom: DesignSystem.spacing.xxs, }, noteRow: { minHeight: 82, borderBottomWidth: StyleSheet.hairlineWidth, flexDirection: 'row', - paddingVertical: 10, + paddingVertical: DesignSystem.spacing.m, + }, + noteDot: { + width: 6, + height: 6, + borderRadius: DesignSystem.radii.pill, + marginTop: DesignSystem.spacing.s, + marginRight: DesignSystem.spacing.m, }, - noteDot: { width: 6, height: 6, borderRadius: 3, marginTop: 7, marginRight: 10 }, noteCopy: { flex: 1, minWidth: 0 }, - noteTitleRow: { flexDirection: 'row', alignItems: 'center', gap: 5 }, - noteTitle: { maxWidth: '88%', fontSize: 14, lineHeight: 18, fontWeight: '600' }, - notePreview: { fontSize: 11, lineHeight: 15, marginTop: 3 }, - noteDate: { fontSize: 9, lineHeight: 13, marginTop: 3 }, + noteTitleRow: { + flexDirection: 'row', + alignItems: 'center', + gap: DesignSystem.spacing.xs, + }, + noteTitle: { + ...DesignSystem.text('label'), + fontWeight: '600', + maxWidth: '88%', + }, + notePreview: { + ...DesignSystem.text('micro'), + fontWeight: '400', + marginTop: DesignSystem.spacing.xs, + }, + noteDate: { + ...DesignSystem.text('micro'), + marginTop: DesignSystem.spacing.xs, + }, centerState: { flex: 1, alignItems: 'center', justifyContent: 'center' }, - emptyState: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingBottom: 60 }, + emptyState: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingBottom: DesignSystem.spacing.huge, + }, emptyIcon: { width: 54, height: 54, - borderRadius: 27, + borderRadius: DesignSystem.radii.pill, alignItems: 'center', justifyContent: 'center', - marginBottom: 16, + marginBottom: DesignSystem.spacing.l, + }, + emptyTitle: { + ...DesignSystem.text('subtitle'), + textAlign: 'center', }, - emptyTitle: { fontSize: 17, lineHeight: 22, fontWeight: '600', textAlign: 'center' }, emptyButton: { - minHeight: 42, + minHeight: DesignSystem.layout.touchTarget.minimum, borderWidth: 1, - borderRadius: 8, - paddingHorizontal: 18, + borderRadius: DesignSystem.radii.small, + paddingHorizontal: DesignSystem.spacing.xl, alignItems: 'center', justifyContent: 'center', - marginTop: 16, + marginTop: DesignSystem.spacing.l, + }, + emptyButtonText: { + ...DesignSystem.text('labelSmall'), + fontWeight: '600', }, - emptyButtonText: { fontSize: 12, fontWeight: '600' }, }); export default NotesScreen; diff --git a/src/screens/OnboardingScreen.tsx b/src/screens/OnboardingScreen.tsx index ad99c93..8ecd2e3 100644 --- a/src/screens/OnboardingScreen.tsx +++ b/src/screens/OnboardingScreen.tsx @@ -6,6 +6,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { MotionPressable, Reveal } from '../components/ui/motion'; import { PinKeypad } from '../components/ui/pin-keypad'; +import { DesignSystem } from '../constants/designSystem'; import { Theme } from '../constants/theme'; import { useAlert } from '../contexts/AlertContext'; import { useAuth } from '../contexts/AuthContext'; @@ -39,8 +40,6 @@ const OnboardingScreen: React.FC = () => { const [confirmPin, setConfirmPin] = React.useState(''); const [isProcessing, setIsProcessing] = React.useState(false); - const contentWidth = Math.min(layout.width - layout.horizontalPadding * 2, 560); - async function handleNext() { Keyboard.dismiss(); @@ -113,7 +112,10 @@ const OnboardingScreen: React.FC = () => { ] as const; return ( - + @@ -131,7 +133,11 @@ const OnboardingScreen: React.FC = () => { {features.map((feature, index) => ( - + {feature.label} @@ -149,7 +155,11 @@ const OnboardingScreen: React.FC = () => { {t('onboarding_setup')} - + {t('onboarding_setup_time')} @@ -161,14 +171,21 @@ const OnboardingScreen: React.FC = () => { function renderProgressHeader(step: Exclude) { return ( - + - + {t('onboarding_step_progress', { current: stepNumber[step], total: 3 })} @@ -179,12 +196,19 @@ const OnboardingScreen: React.FC = () => { function renderName() { return ( - + {renderProgressHeader('name')} - + {t('onboarding_name_question')} @@ -227,14 +251,17 @@ const OnboardingScreen: React.FC = () => { const hasMismatch = isConfirmation && confirmPin.length === 6 && confirmPin !== pin; return ( - + {renderProgressHeader(isConfirmation ? 'confirm_pin' : 'pin')} @@ -324,7 +351,7 @@ const OnboardingScreen: React.FC = () => { @@ -342,7 +369,7 @@ const OnboardingScreen: React.FC = () => { keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false} > - + {currentStep === 'welcome' && renderWelcome()} {currentStep === 'name' && renderName()} {currentStep === 'pin' && renderPinStep(false)} @@ -372,108 +399,134 @@ function createStyles(theme: Theme) { return StyleSheet.create({ safeArea: { flex: 1 }, scrollContent: { flexGrow: 1, alignItems: 'center' }, - content: { flex: 1, paddingHorizontal: 20, paddingVertical: 20 }, + content: { + flex: 1, + width: '100%', + maxWidth: DesignSystem.layout.maxFormWidth, + alignSelf: 'center', + paddingVertical: DesignSystem.spacing.xl, + }, step: { flex: 1, minHeight: 620, justifyContent: 'space-between' }, - welcomeBody: { flex: 1, justifyContent: 'center', paddingBottom: 28 }, - lockMark: { alignItems: 'center', marginBottom: 30 }, + stepShortLandscape: { minHeight: 0 }, + welcomeBody: { + flex: 1, + justifyContent: 'center', + paddingBottom: DesignSystem.spacing.xxl, + }, + lockMark: { alignItems: 'center', marginBottom: DesignSystem.spacing.xxl }, eyebrow: { - fontSize: 10, + ...DesignSystem.text('micro'), fontWeight: '700', - letterSpacing: 1.8, textAlign: 'center', - marginBottom: 12, + marginBottom: DesignSystem.spacing.m, }, heroTitle: { - fontSize: 31, - lineHeight: 35, - fontWeight: '600', - letterSpacing: -0.7, + ...DesignSystem.text('display'), textAlign: 'center', maxWidth: 300, alignSelf: 'center', }, heroDescription: { - fontSize: 15, - lineHeight: 22, + ...DesignSystem.text('body'), textAlign: 'center', maxWidth: 310, alignSelf: 'center', - marginTop: 14, + marginTop: DesignSystem.spacing.l, }, - features: { marginTop: 34 }, + features: { marginTop: DesignSystem.spacing.xxl }, featureRow: { - minHeight: 46, + minHeight: DesignSystem.layout.touchTarget.minimum, flexDirection: 'row', alignItems: 'center', - gap: 12, + gap: DesignSystem.spacing.m, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: theme.colors.divider, }, - featureText: { fontSize: 13, lineHeight: 18, fontWeight: '500' }, + featureText: { ...DesignSystem.text('labelSmall') }, primaryButton: { - minHeight: 50, + minHeight: DesignSystem.layout.touchTarget.comfortable, borderWidth: 1, - borderRadius: 8, + borderRadius: DesignSystem.radii.small, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', - gap: 8, - paddingHorizontal: 18, + gap: DesignSystem.spacing.s, + paddingHorizontal: DesignSystem.spacing.l, + }, + primaryButtonText: { ...DesignSystem.text('button') }, + footerHint: { + ...DesignSystem.text('micro'), + textAlign: 'center', + marginTop: DesignSystem.spacing.m, }, - primaryButtonText: { fontSize: 14, fontWeight: '600' }, - footerHint: { fontSize: 11, lineHeight: 15, textAlign: 'center', marginTop: 12 }, progressHeader: { - minHeight: 44, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', }, - iconButton: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, - progressText: { fontSize: 11, fontWeight: '600', letterSpacing: 0.5 }, - questionBlock: { alignItems: 'center', marginTop: 54, marginBottom: 32 }, + iconButton: { alignItems: 'center', justifyContent: 'center' }, + progressText: { ...DesignSystem.text('micro') }, + questionBlock: { + alignItems: 'center', + marginTop: DesignSystem.spacing.xxxl, + marginBottom: DesignSystem.spacing.xxl, + }, smallMark: { - width: 48, - height: 48, - borderRadius: 24, + width: DesignSystem.layout.touchTarget.comfortable, + height: DesignSystem.layout.touchTarget.comfortable, + borderRadius: DesignSystem.radii.pill, alignItems: 'center', justifyContent: 'center', - marginBottom: 18, + marginBottom: DesignSystem.spacing.l, }, questionTitle: { - fontSize: 25, - lineHeight: 31, - fontWeight: '600', - letterSpacing: -0.35, + ...DesignSystem.text('headline'), + textAlign: 'center', + }, + questionHint: { + ...DesignSystem.text('bodySmall'), textAlign: 'center', + marginTop: DesignSystem.spacing.s, }, - questionHint: { fontSize: 14, lineHeight: 20, textAlign: 'center', marginTop: 8 }, textInput: { width: '100%', minHeight: 52, borderWidth: 1, - borderRadius: 8, - paddingHorizontal: 14, - fontSize: 16, + borderRadius: DesignSystem.radii.small, + paddingHorizontal: DesignSystem.spacing.m, + ...DesignSystem.text('body'), textAlign: 'center', }, - inputHint: { fontSize: 11, lineHeight: 16, textAlign: 'center', marginTop: 10 }, + inputHint: { + ...DesignSystem.text('micro'), + textAlign: 'center', + marginTop: DesignSystem.spacing.m, + }, pinDots: { flexDirection: 'row', justifyContent: 'center', - gap: 12, - marginBottom: 38, + gap: DesignSystem.spacing.m, + marginBottom: DesignSystem.spacing.xxl, + }, + pinDot: { + width: 10, + height: 10, + borderRadius: DesignSystem.radii.pill, + borderWidth: 1, }, - pinDot: { width: 10, height: 10, borderRadius: 5, borderWidth: 1 }, pinGuidance: { - minHeight: 44, - fontSize: 11, - lineHeight: 16, + minHeight: DesignSystem.layout.touchTarget.minimum, + ...DesignSystem.text('micro'), + textAlign: 'center', + marginTop: DesignSystem.spacing.l, + paddingHorizontal: DesignSystem.spacing.l, + }, + processingBody: { alignItems: 'center', paddingVertical: DesignSystem.spacing.m }, + processingText: { + ...DesignSystem.text('caption'), + marginTop: DesignSystem.spacing.m, textAlign: 'center', - marginTop: 16, - paddingHorizontal: 16, }, - processingBody: { alignItems: 'center', paddingVertical: 12 }, - processingText: { fontSize: 13, lineHeight: 19, marginTop: 12, textAlign: 'center' }, }); } diff --git a/src/screens/OpenSourceScreen.tsx b/src/screens/OpenSourceScreen.tsx index d38a744..7581592 100644 --- a/src/screens/OpenSourceScreen.tsx +++ b/src/screens/OpenSourceScreen.tsx @@ -18,6 +18,7 @@ import { DesignSystem } from '../constants/designSystem'; import { Theme } from '../constants/theme'; import { useLanguage } from '../contexts/LanguageContext'; import { useTheme } from '../contexts/ThemeContext'; +import { useResponsiveLayout } from '../utils/responsive'; import { useWebScrollFix } from '../utils/webScrollFix'; import { RootStackParamList } from '../navigation'; @@ -34,6 +35,7 @@ const TRADEMARKS_POLICY_URL = `${SOURCE_REPOSITORY_URL}/blob/main/TRADEMARKS.md` const OpenSourceScreen: React.FC = ({ navigation }) => { const { theme } = useTheme(); const { t } = useLanguage(); + const layout = useResponsiveLayout(); const styles = React.useMemo(() => createStyles(theme), [theme]); const [licenseText, setLicenseText] = React.useState(null); const [hasLicenseLoadError, setHasLicenseLoadError] = React.useState(false); @@ -75,7 +77,10 @@ const OpenSourceScreen: React.FC = ({ navigation }) => { = ({ navigation }) => { /> {t('open_source')} - + { accessibilityLabel={isPasswordVisible ? t('hide_password') : t('show_password')} accessibilityRole="button" onPress={togglePasswordVisibility} - style={styles.detailAction} + style={[ + styles.detailAction, + { width: layout.minTouchTarget, height: layout.minTouchTarget }, + ]} > @@ -356,9 +359,16 @@ const PasswordDetailScreen: React.FC = () => { accessibilityLabel={options?.copyLabel || t('copy')} accessibilityRole="button" onPress={() => void handleCopyToClipboard(value)} - style={styles.detailAction} + style={[ + styles.detailAction, + { width: layout.minTouchTarget, height: layout.minTouchTarget }, + ]} > - + @@ -366,30 +376,42 @@ const PasswordDetailScreen: React.FC = () => { } if (screenMode === 'view' && passwordId) { - const detailWidth = Math.min(layout.width - layout.horizontalPadding * 2, 660); - return ( - - + + navigation.goBack()} - style={styles.headerIconButton} + style={[ + styles.headerIconButton, + { width: layout.minTouchTarget, height: layout.minTouchTarget }, + ]} > - + - + @@ -403,7 +425,7 @@ const PasswordDetailScreen: React.FC = () => { > @@ -457,7 +479,11 @@ const PasswordDetailScreen: React.FC = () => { onPress={() => setScreenMode('edit')} style={[styles.outlineButton, { borderColor: theme.colors.primary }]} > - + {t('edit_item')} @@ -468,7 +494,11 @@ const PasswordDetailScreen: React.FC = () => { style={styles.destructiveButton} testID="password-delete-button" > - + {t('delete')} @@ -494,9 +524,7 @@ const PasswordDetailScreen: React.FC = () => { { style={styles.backButton} accessibilityRole="button" accessibilityLabel={t('back')} - hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + hitSlop={DesignSystem.spacing.s} > - + {screenMode === 'edit' ? t('edit_item') : t('new_password')} {screenMode === 'edit' ? ( @@ -534,7 +551,7 @@ const PasswordDetailScreen: React.FC = () => { style={styles.headerSaveButton} accessibilityRole="button" accessibilityLabel={t('save')} - hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + hitSlop={DesignSystem.spacing.s} > {t('save')} @@ -547,7 +564,10 @@ const PasswordDetailScreen: React.FC = () => { { }, ]} placeholder={t('title')} - placeholderTextColor={theme.colors.text + '80'} + placeholderTextColor={theme.colors.textTertiary} value={password.title} onChangeText={(text: string) => setPassword({ ...password, title: text })} /> @@ -585,7 +605,7 @@ const PasswordDetailScreen: React.FC = () => { testID="password-username-input" style={[styles.inputFlex, { color: theme.colors.text }]} placeholder={t('username_placeholder')} - placeholderTextColor={theme.colors.text + '80'} + placeholderTextColor={theme.colors.textTertiary} value={password.username} onChangeText={(text: string) => setPassword({ ...password, username: text })} autoCapitalize="none" @@ -595,9 +615,13 @@ const PasswordDetailScreen: React.FC = () => { style={styles.inputIcon} accessibilityRole="button" accessibilityLabel={t('copy_username')} - hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} + hitSlop={DesignSystem.spacing.s} > - + @@ -614,7 +638,7 @@ const PasswordDetailScreen: React.FC = () => { testID="password-password-input" style={[styles.inputFlex, { color: theme.colors.text }]} placeholder={t('password')} - placeholderTextColor={theme.colors.text + '80'} + placeholderTextColor={theme.colors.textTertiary} value={password.password} onChangeText={(text: string) => setPassword({ ...password, password: text })} secureTextEntry={!isPasswordVisible} @@ -625,11 +649,11 @@ const PasswordDetailScreen: React.FC = () => { style={styles.inputIcon} accessibilityRole="button" accessibilityLabel={isPasswordVisible ? t('hide_password') : t('show_password')} - hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} + hitSlop={DesignSystem.spacing.s} > @@ -638,9 +662,13 @@ const PasswordDetailScreen: React.FC = () => { style={styles.inputIcon} accessibilityRole="button" accessibilityLabel={t('copy_password_card')} - hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} + hitSlop={DesignSystem.spacing.s} > - + @@ -681,13 +709,18 @@ const PasswordDetailScreen: React.FC = () => { backgroundColor: theme.colors.background, borderColor: theme.colors.primary, borderWidth: 1, - borderRadius: AppTheme.borderRadius.medium, + borderRadius: DesignSystem.radii.medium, + minHeight: layout.minTouchTarget, }, ]} accessibilityRole="button" accessibilityLabel={t('generate_secure_password')} > - + {t('generate_secure_password')} @@ -705,7 +738,7 @@ const PasswordDetailScreen: React.FC = () => { setPassword({ ...password, website: text })} autoCapitalize="none" @@ -717,9 +750,13 @@ const PasswordDetailScreen: React.FC = () => { style={styles.inputIcon} accessibilityRole="button" accessibilityLabel={t('copy_website')} - hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} + hitSlop={DesignSystem.spacing.s} > - + ) : null} @@ -740,7 +777,7 @@ const PasswordDetailScreen: React.FC = () => { value={password.notes || ''} onChangeText={(text: string) => setPassword({ ...password, notes: text })} placeholder={t('notes')} - placeholderTextColor={theme.colors.text + '80'} + placeholderTextColor={theme.colors.textTertiary} multiline numberOfLines={4} textAlignVertical="top" @@ -768,7 +805,7 @@ const PasswordDetailScreen: React.FC = () => { > @@ -777,11 +814,15 @@ const PasswordDetailScreen: React.FC = () => { {password.category ? getCategoryName(password.category) : t('select_category')} - + - + { style={styles.destructiveButton} testID="password-delete-button" > - + {t('delete')} @@ -819,7 +864,7 @@ const PasswordDetailScreen: React.FC = () => { onClose={() => setShowCategoryModal(false)} title={t('select_category')} > - + {categoryOptions.map((item) => ( { excellent: t('excellent'), }; const colors: PasswordGeneratorStrengthMap = { - weak: AppTheme.colors.error, - medium: AppTheme.colors.warning, - excellent: AppTheme.colors.success, + weak: theme.colors.error, + medium: theme.colors.warning, + excellent: theme.colors.success, }; return calculatePasswordGeneratorStrength({ @@ -114,7 +115,17 @@ const PasswordGeneratorScreen: React.FC = () => { labels, colors, }); - }, [includeLowercase, includeNumbers, includeSymbols, includeUppercase, passwordLength, t]); + }, [ + includeLowercase, + includeNumbers, + includeSymbols, + includeUppercase, + passwordLength, + t, + theme.colors.error, + theme.colors.success, + theme.colors.warning, + ]); const entropyBits = useMemo(() => { const poolSize = @@ -151,34 +162,38 @@ const PasswordGeneratorScreen: React.FC = () => { ]} > {label} - + ); } - const contentWidth = Math.min(layout.width - layout.horizontalPadding * 2, 680); - return ( - - + + {onSelect ? ( navigation.goBack()} - style={styles.headerButton} + style={[ + styles.headerButton, + { width: layout.minTouchTarget, height: layout.minTouchTarget }, + ]} > - + ) : null} {t('tab_generator')} @@ -214,7 +229,11 @@ const PasswordGeneratorScreen: React.FC = () => { onPress={generatePassword} style={[styles.actionButton, { borderColor: theme.colors.primary }]} > - + {t('regenerate')} @@ -224,7 +243,11 @@ const PasswordGeneratorScreen: React.FC = () => { onPress={() => void handleCopyToClipboard()} style={[styles.actionButton, { borderColor: theme.colors.border }]} > - + {t('copy')} @@ -294,73 +317,105 @@ const PasswordGeneratorScreen: React.FC = () => { }; const styles = StyleSheet.create({ - safeArea: { flex: 1, alignItems: 'center' }, + safeArea: { flex: 1 }, content: { flex: 1 }, - header: { minHeight: 58, flexDirection: 'row', alignItems: 'center' }, + header: { + flexDirection: 'row', + alignItems: 'center', + gap: DesignSystem.spacing.s, + }, headerButton: { - width: 42, - height: 44, alignItems: 'center', justifyContent: 'center', - marginLeft: -8, + marginLeft: -DesignSystem.spacing.s, + }, + title: { + ...DesignSystem.text('headline'), }, - title: { fontSize: 23, lineHeight: 29, fontWeight: '600', letterSpacing: -0.35 }, - scrollContent: { paddingBottom: 24 }, + scrollContent: { paddingBottom: DesignSystem.spacing.xl }, passwordPanel: { - borderRadius: 8, - padding: 14, + borderRadius: DesignSystem.radii.small, + padding: DesignSystem.spacing.l, minHeight: 108, alignItems: 'center', justifyContent: 'center', }, passwordText: { - fontFamily: AppTheme.fonts.secure, - fontSize: 16, - lineHeight: 23, - letterSpacing: 0.6, + ...DesignSystem.text('body'), + fontFamily: DesignSystem.fonts.secure, textAlign: 'center', }, - strengthTrack: { width: '100%', height: 2, marginTop: 16 }, + strengthTrack: { width: '100%', height: 2, marginTop: DesignSystem.spacing.l }, strengthFill: { width: '100%', height: 2 }, - strengthText: { fontSize: 10, lineHeight: 14, marginTop: 7 }, - actions: { flexDirection: 'row', gap: 7, marginTop: 8 }, + strengthText: { + ...DesignSystem.text('micro'), + marginTop: DesignSystem.spacing.s, + }, + actions: { + flexDirection: 'row', + gap: DesignSystem.spacing.s, + marginTop: DesignSystem.spacing.s, + }, actionButton: { flex: 1, - minHeight: 42, + minHeight: DesignSystem.layout.touchTarget.minimum, borderWidth: 1, - borderRadius: 8, + borderRadius: DesignSystem.radii.small, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', - gap: 7, + gap: DesignSystem.spacing.s, + }, + actionText: { + ...DesignSystem.text('labelSmall'), + fontWeight: '600', }, - actionText: { fontSize: 12, lineHeight: 16, fontWeight: '600' }, - options: { marginTop: 20 }, + options: { marginTop: DesignSystem.spacing.xl }, sectionLabel: { - fontSize: 9, - lineHeight: 13, + ...DesignSystem.text('micro'), fontWeight: '700', - letterSpacing: 1.2, textTransform: 'uppercase', - marginBottom: 4, + marginBottom: DesignSystem.spacing.xs, + }, + lengthBlock: { + paddingVertical: DesignSystem.spacing.m, + borderBottomWidth: StyleSheet.hairlineWidth, }, - lengthBlock: { paddingVertical: 12, borderBottomWidth: StyleSheet.hairlineWidth }, lengthHeading: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, - optionLabel: { fontSize: 13, lineHeight: 18, fontWeight: '500', flex: 1 }, - lengthValue: { fontSize: 12, lineHeight: 16, fontWeight: '600' }, - slider: { width: '100%', height: 34, marginTop: 6 }, - sliderLabels: { flexDirection: 'row', justifyContent: 'space-between', marginTop: -5 }, - sliderLabel: { fontSize: 9, lineHeight: 12 }, - optionRow: { minHeight: 54, flexDirection: 'row', alignItems: 'center' }, + optionLabel: { + ...DesignSystem.text('labelSmall'), + flex: 1, + }, + lengthValue: { + ...DesignSystem.text('labelSmall'), + fontWeight: '600', + }, + slider: { width: '100%', height: 34, marginTop: DesignSystem.spacing.xs }, + sliderLabels: { + flexDirection: 'row', + justifyContent: 'space-between', + marginTop: -DesignSystem.spacing.xs, + }, + sliderLabel: { + ...DesignSystem.text('micro'), + }, + optionRow: { + minHeight: DesignSystem.layout.listItemMinHeight, + flexDirection: 'row', + alignItems: 'center', + }, useButton: { - minHeight: 46, + minHeight: DesignSystem.layout.touchTarget.comfortable, borderWidth: 1, - borderRadius: 8, + borderRadius: DesignSystem.radii.small, alignItems: 'center', justifyContent: 'center', - marginTop: 18, + marginTop: DesignSystem.spacing.l, + }, + useButtonText: { + ...DesignSystem.text('labelSmall'), + fontWeight: '600', }, - useButtonText: { fontSize: 13, lineHeight: 18, fontWeight: '600' }, }); export default PasswordGeneratorScreen; diff --git a/src/screens/PrivacyPolicyScreen.tsx b/src/screens/PrivacyPolicyScreen.tsx index da21aa1..83ae201 100644 --- a/src/screens/PrivacyPolicyScreen.tsx +++ b/src/screens/PrivacyPolicyScreen.tsx @@ -18,6 +18,7 @@ import { DesignSystem } from '../constants/designSystem'; import { Theme } from '../constants/theme'; import { SUPPORT_EMAIL, WEBSITE_HOST, WEBSITE_URL } from '../constants/contact'; import { copyPlainTextWithFeedback } from '../utils/clipboardUtils'; +import { useResponsiveLayout } from '../utils/responsive'; import { useWebScrollFix } from '../utils/webScrollFix'; interface PrivacyPolicyScreenProps { @@ -93,6 +94,7 @@ const PrivacyPolicyScreen: React.FC = ({ navigation }) const { theme } = useTheme(); const { t } = useLanguage(); const { notify } = useAlert(); + const layout = useResponsiveLayout(); const styles = React.useMemo(() => createStyles(theme), [theme]); // Apply web scroll fixes @@ -113,7 +115,10 @@ const PrivacyPolicyScreen: React.FC = ({ navigation }) navigation.goBack()} accessibilityRole="button" accessibilityLabel={t('back')} @@ -125,7 +130,7 @@ const PrivacyPolicyScreen: React.FC = ({ navigation }) /> {t('privacy_policy')} - + { setShowExportDialog, handleExportPasswords, performExport, + busyOperation, importPassword, setImportPassword, showImportPasswordDialog, @@ -680,18 +681,7 @@ const SettingsScreen: React.FC = () => { }, ]} > - - {t('settings')} - + {t('settings')} { {getAutoLockTimeoutText()} - + } style={[styles.compactRow, { borderBottomColor: theme.colors.divider }]} @@ -791,10 +785,14 @@ const SettingsScreen: React.FC = () => { ? t('clipboard_5min') : `${Math.floor((preferences?.clipboardClearTimeout || 0) / 60)} min`} - + } - style={styles.compactRow} + style={[styles.compactRow, { borderBottomColor: theme.colors.divider }]} /> @@ -813,7 +811,11 @@ const SettingsScreen: React.FC = () => { {getThemeText()} - + } style={[styles.compactRow, { borderBottomColor: theme.colors.divider }]} @@ -831,7 +833,11 @@ const SettingsScreen: React.FC = () => { ? t('language_italian') : t('language_english')} - + } style={[styles.compactRow, { borderBottomColor: theme.colors.divider }]} @@ -846,7 +852,7 @@ const SettingsScreen: React.FC = () => { value={securityRemindersEnabled} /> } - style={styles.compactRow} + style={[styles.compactRow, { borderBottomColor: theme.colors.divider }]} /> @@ -868,7 +874,7 @@ const SettingsScreen: React.FC = () => { title={t('import_data')} onPress={handleImportPasswords} rightIcon="chevron-forward" - style={styles.compactRow} + style={[styles.compactRow, { borderBottomColor: theme.colors.divider }]} /> @@ -914,7 +920,7 @@ const SettingsScreen: React.FC = () => { title={t('open_source')} onPress={() => navigation.navigate('OpenSource')} rightIcon="chevron-forward" - style={styles.compactRow} + style={[styles.compactRow, { borderBottomColor: theme.colors.divider }]} /> @@ -930,7 +936,7 @@ const SettingsScreen: React.FC = () => { title={t('sponsor_github')} onPress={() => Linking.openURL('https://github.com/sponsors/TheStreamCode')} rightIcon="open-outline" - style={styles.compactRow} + style={[styles.compactRow, { borderBottomColor: theme.colors.divider }]} /> @@ -954,7 +960,7 @@ const SettingsScreen: React.FC = () => { title={t('reset_app')} onPress={() => setShowResetModal(true)} rightIcon="chevron-forward" - style={styles.compactRow} + style={[styles.compactRow, { borderBottomColor: theme.colors.divider }]} /> @@ -967,12 +973,7 @@ const SettingsScreen: React.FC = () => { title={t('change_pin_title')} > - + {t('pin_requirement')} @@ -980,16 +981,15 @@ const SettingsScreen: React.FC = () => { style={[ styles.inputContainer, { - borderColor: theme.colors.border, - backgroundColor: theme.colors.backgroundLight, - marginBottom: AppTheme.spacing.m, + borderColor: theme.colors.inputBorder, + backgroundColor: theme.colors.inputBackground, }, ]} > { > @@ -1012,16 +1012,15 @@ const SettingsScreen: React.FC = () => { style={[ styles.inputContainer, { - borderColor: theme.colors.border, - backgroundColor: theme.colors.backgroundLight, - marginBottom: AppTheme.spacing.m, + borderColor: theme.colors.inputBorder, + backgroundColor: theme.colors.inputBackground, }, ]} > { setShowNewPin(!showNewPin)} style={styles.eyeIcon}> @@ -1041,16 +1040,15 @@ const SettingsScreen: React.FC = () => { style={[ styles.inputContainer, { - borderColor: theme.colors.border, - backgroundColor: theme.colors.backgroundLight, - marginBottom: AppTheme.spacing.m, + borderColor: theme.colors.inputBorder, + backgroundColor: theme.colors.inputBackground, }, ]} > { > {pinError ? ( - - {pinError} - + {pinError} ) : null} @@ -1106,23 +1097,27 @@ const SettingsScreen: React.FC = () => { accessibilityLabel={t('tap_to_change_image')} accessibilityRole="button" onPress={openImageSourceModal} - style={[ - styles.avatarContainer, - { alignItems: 'center', marginBottom: AppTheme.spacing.l }, - ]} + style={styles.avatarContainer} > - - - - - - - + + + + + + {t('tap_to_change_image')} @@ -1131,14 +1126,17 @@ const SettingsScreen: React.FC = () => { style={[ styles.profileInputContainer, { - backgroundColor: theme.colors.backgroundLight, - borderColor: theme.colors.border, - marginBottom: AppTheme.spacing.m, + backgroundColor: theme.colors.inputBackground, + borderColor: theme.colors.inputBorder, }, ]} > - + { {profileError ? ( - - {profileError} - + {profileError} ) : null} @@ -1190,12 +1181,7 @@ const SettingsScreen: React.FC = () => { title={t('export_passwords_title')} > - + {t('export_passwords_message_encrypted_only')} @@ -1205,9 +1191,8 @@ const SettingsScreen: React.FC = () => { style={[ styles.inputContainer, { - borderColor: theme.colors.border, - backgroundColor: theme.colors.backgroundLight, - marginBottom: AppTheme.spacing.m, + borderColor: theme.colors.inputBorder, + backgroundColor: theme.colors.inputBackground, }, ]} > @@ -1217,7 +1202,7 @@ const SettingsScreen: React.FC = () => { onChangeText={setExportPassword} secureTextEntry placeholder={t('enter_encryption_password')} - placeholderTextColor={theme.colors.text + '60'} + placeholderTextColor={theme.colors.textTertiary} /> @@ -1254,12 +1239,7 @@ const SettingsScreen: React.FC = () => { title={t('import_encrypted')} > - + {t('enter_decryption_password')} @@ -1267,9 +1247,8 @@ const SettingsScreen: React.FC = () => { style={[ styles.inputContainer, { - borderColor: theme.colors.border, - backgroundColor: theme.colors.backgroundLight, - marginBottom: AppTheme.spacing.m, + borderColor: theme.colors.inputBorder, + backgroundColor: theme.colors.inputBackground, }, ]} > @@ -1279,7 +1258,7 @@ const SettingsScreen: React.FC = () => { onChangeText={setImportPassword} secureTextEntry placeholder={t('decryption_password_placeholder')} - placeholderTextColor={theme.colors.text + '60'} + placeholderTextColor={theme.colors.textTertiary} /> @@ -1421,7 +1400,12 @@ const SettingsScreen: React.FC = () => { {t('reset_app_confirm_prompt')} @@ -1455,6 +1439,15 @@ const SettingsScreen: React.FC = () => { title={t('updating_pin')} visible={isChangingPin} /> + + } + onClose={() => {}} + title={busyOperation === 'export' ? t('export_in_progress') : t('import_in_progress')} + visible={busyOperation !== null} + /> ); }; @@ -1473,144 +1466,132 @@ const styles = StyleSheet.create({ justifyContent: 'space-between', alignItems: 'center', width: '100%', - maxWidth: 680, - alignSelf: 'center', - minHeight: 58, - paddingHorizontal: 14, - paddingVertical: 8, + minHeight: DesignSystem.layout.touchTarget.large, + paddingVertical: DesignSystem.spacing.s, }, title: { + ...DesignSystem.text('titleLarge'), textAlign: 'left', - letterSpacing: -0.35, }, compactScrollView: { flex: 1 }, compactScrollContent: { width: '100%', - maxWidth: 680, - alignSelf: 'center', - paddingHorizontal: 14, - paddingBottom: 24, + paddingBottom: DesignSystem.spacing.xl, }, compactProfile: { minHeight: 66, - borderRadius: 8, - paddingHorizontal: 12, + borderRadius: DesignSystem.radii.small, + paddingHorizontal: DesignSystem.spacing.m, flexDirection: 'row', alignItems: 'center', - marginBottom: 8, + marginBottom: DesignSystem.spacing.s, }, - compactProfileCopy: { flex: 1, minWidth: 0, marginLeft: 10 }, - compactProfileName: { fontSize: 14, lineHeight: 18, fontWeight: '600' }, - compactProfileMeta: { fontSize: 10, lineHeight: 14, marginTop: 1 }, - compactEditText: { fontSize: 11, lineHeight: 15, fontWeight: '600' }, + compactProfileCopy: { flex: 1, minWidth: 0, marginLeft: DesignSystem.spacing.s }, + compactProfileName: { ...DesignSystem.text('label'), fontWeight: '600' }, + compactProfileMeta: { ...DesignSystem.text('micro'), marginTop: DesignSystem.spacing.xxs }, + compactEditText: { ...DesignSystem.text('micro'), fontWeight: '600' }, compactSectionLabel: { - fontSize: 9, - lineHeight: 13, + ...DesignSystem.text('micro'), fontWeight: '700', - letterSpacing: 1.1, textTransform: 'uppercase', - marginTop: 12, - marginBottom: 2, + marginTop: DesignSystem.spacing.m, + marginBottom: DesignSystem.spacing.xxs, }, compactGroup: { overflow: 'hidden' }, compactRow: { borderBottomWidth: StyleSheet.hairlineWidth }, - compactValueRow: { flexDirection: 'row', alignItems: 'center', gap: 4 }, - compactValue: { fontSize: 10, lineHeight: 14 }, + compactValueRow: { flexDirection: 'row', alignItems: 'center', gap: DesignSystem.spacing.xs }, + compactValue: { ...DesignSystem.text('micro') }, profileInputContainer: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, - borderRadius: AppTheme.borderRadius.medium, - marginTop: AppTheme.spacing.m, - marginBottom: AppTheme.spacing.m, - height: 48, // Altezza fissa per evitare espansioni + borderRadius: DesignSystem.radii.medium, + marginTop: DesignSystem.spacing.m, + marginBottom: DesignSystem.spacing.m, + height: DesignSystem.layout.touchTarget.comfortable, overflow: 'hidden', }, profileInputIcon: { - width: 48, - height: 48, + width: DesignSystem.layout.touchTarget.comfortable, + height: DesignSystem.layout.touchTarget.comfortable, justifyContent: 'center', alignItems: 'center', - flexShrink: 0, // Does not shrink + flexShrink: 0, }, profileTextInput: { flex: 1, - fontSize: AppTheme.fonts.sizes.medium, - paddingHorizontal: AppTheme.spacing.s, - paddingVertical: 0, // Rimuove padding verticale per controllo preciso dell'altezza - height: 46, // Altezza leggermente inferiore al container - textAlignVertical: 'center', // Android + ...DesignSystem.text('bodySmall'), + paddingHorizontal: DesignSystem.spacing.s, + paddingVertical: 0, + height: 46, // One border less than the container height. + textAlignVertical: 'center', }, inputContainer: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, - borderRadius: AppTheme.borderRadius.medium, - marginBottom: AppTheme.spacing.m, + borderRadius: DesignSystem.radii.medium, + marginBottom: DesignSystem.spacing.m, overflow: 'hidden', }, modalInput: { flex: 1, - padding: AppTheme.spacing.m, - fontSize: AppTheme.fonts.sizes.medium, + padding: DesignSystem.spacing.m, + ...DesignSystem.text('bodySmall'), }, eyeIcon: { - padding: AppTheme.spacing.m, + padding: DesignSystem.spacing.m, justifyContent: 'center', alignItems: 'center', }, errorText: { - fontSize: AppTheme.fonts.sizes.small, - marginBottom: AppTheme.spacing.m, + ...DesignSystem.text('caption'), + marginBottom: DesignSystem.spacing.m, textAlign: 'center', }, avatarContainer: { alignItems: 'center', - marginBottom: AppTheme.spacing.m, - paddingVertical: AppTheme.spacing.s, // Aggiungiamo padding verticale + marginBottom: DesignSystem.spacing.l, + paddingVertical: DesignSystem.spacing.s, }, editAvatarContainer: { - width: 80, // Ridotto da 100 a 80 - height: 80, // Ridotto da 100 a 80 - borderRadius: 40, // Updated for the new size + width: 80, + height: 80, + borderRadius: DesignSystem.radii.pill, overflow: 'visible', - backgroundColor: AppTheme.colors.primary + '15', justifyContent: 'center', alignItems: 'center', position: 'relative', - marginBottom: AppTheme.spacing.s, + marginBottom: DesignSystem.spacing.s, borderWidth: 2, - borderColor: AppTheme.colors.primary, }, avatarHint: { - fontSize: AppTheme.fonts.sizes.small, - marginTop: 4, + ...DesignSystem.text('caption'), + marginTop: DesignSystem.spacing.s, textAlign: 'center', - paddingHorizontal: AppTheme.spacing.s, // Padding per evitare che il testo tocchi i bordi + paddingHorizontal: DesignSystem.spacing.s, }, cameraIconContainer: { position: 'absolute', - bottom: -4, - right: -4, - backgroundColor: AppTheme.colors.primary, - borderRadius: 16, // Ridotto da 18 a 16 - padding: 4, // Ridotto da 6 a 4 + bottom: -DesignSystem.spacing.xs, + right: -DesignSystem.spacing.xs, + borderRadius: DesignSystem.radii.large, + padding: DesignSystem.spacing.xs, zIndex: 10, - ...AppTheme.shadows.small, + ...DesignSystem.shadows.small, }, pinRequirement: { - fontSize: AppTheme.fonts.sizes.small, - marginBottom: AppTheme.spacing.m, + ...DesignSystem.text('caption'), + marginBottom: DesignSystem.spacing.m, textAlign: 'center', }, modalText: { - fontSize: AppTheme.fonts.sizes.medium, - marginBottom: AppTheme.spacing.m, - // Color is not specified here because it is set dynamically in JSX + ...DesignSystem.text('bodySmall'), + marginBottom: DesignSystem.spacing.m, + // The color is applied dynamically in the JSX. }, modalDescription: { - fontSize: AppTheme.fonts.sizes.medium, - lineHeight: AppTheme.fonts.sizes.medium * 1.5, + ...DesignSystem.text('bodySmall'), textAlign: 'left', }, }); diff --git a/src/screens/VaultHealthScreen.tsx b/src/screens/VaultHealthScreen.tsx index f121c49..0d7179f 100644 --- a/src/screens/VaultHealthScreen.tsx +++ b/src/screens/VaultHealthScreen.tsx @@ -20,6 +20,7 @@ import { VaultHealthSummary, } from '../services/vault-health/vaultHealthService'; import Logger from '../utils/logger'; +import { useResponsiveLayout } from '../utils/responsive'; type HealthFilter = 'all' | VaultHealthKind; @@ -38,6 +39,7 @@ const VaultHealthScreen: React.FC = () => { const navigation = useNavigation>(); const { theme } = useTheme(); const { t } = useLanguage(); + const layout = useResponsiveLayout(); const styles = React.useMemo(() => createStyles(theme), [theme]); const [passwords, setPasswords] = React.useState([]); const [summary, setSummary] = React.useState(EMPTY_SUMMARY); @@ -112,7 +114,7 @@ const VaultHealthScreen: React.FC = () => { .join(', ')}`} > - + @@ -122,7 +124,11 @@ const VaultHealthScreen: React.FC = () => { {item.kinds.map((kind) => t(`vault_health_kind_${kind}`)).join(' · ')} - + ); }, @@ -131,23 +137,35 @@ const VaultHealthScreen: React.FC = () => { const header = ( - + [styles.iconButton, pressed && styles.pressed]} + style={({ pressed }) => [ + styles.iconButton, + { width: layout.minTouchTarget, height: layout.minTouchTarget }, + pressed && styles.pressed, + ]} accessibilityRole="button" accessibilityLabel={t('back')} > - + {t('vault_health_title')} [styles.iconButton, pressed && styles.pressed]} + style={({ pressed }) => [ + styles.iconButton, + { width: layout.minTouchTarget, height: layout.minTouchTarget }, + pressed && styles.pressed, + ]} accessibilityRole="button" accessibilityLabel={t('refresh')} > - + @@ -251,29 +269,23 @@ function createStyles(theme: Theme) { listContent: { paddingBottom: 112 }, headerContent: { gap: DesignSystem.spacing.m }, topBar: { - minHeight: 56, - paddingHorizontal: DesignSystem.spacing.m, flexDirection: 'row', alignItems: 'center', }, iconButton: { - width: 44, - height: 44, alignItems: 'center', justifyContent: 'center', - borderRadius: DesignSystem.borderRadius.small, + borderRadius: DesignSystem.radii.small, }, screenTitle: { flex: 1, textAlign: 'center', - ...DesignSystem.typography.title, - color: theme.colors.text, + ...DesignSystem.text('titleLarge', theme.colors.text), }, summaryPanel: { - marginHorizontal: DesignSystem.spacing.m, padding: DesignSystem.spacing.l, gap: DesignSystem.spacing.m, - borderRadius: DesignSystem.borderRadius.large, + borderRadius: DesignSystem.radii.large, backgroundColor: theme.colors.backgroundElevated, borderWidth: 1, borderColor: theme.colors.border, @@ -282,46 +294,48 @@ function createStyles(theme: Theme) { flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', - gap: 12, + gap: DesignSystem.spacing.m, }, summaryCopy: { flex: 1, minWidth: 0 }, eyebrow: { - ...DesignSystem.typography.caption, - color: theme.colors.textSecondary, + ...DesignSystem.text('micro', theme.colors.textSecondary), textTransform: 'uppercase', - letterSpacing: 1.2, }, - summaryTitle: { ...DesignSystem.typography.headline, color: theme.colors.text, marginTop: 4 }, + summaryTitle: { + ...DesignSystem.text('headline', theme.colors.text), + marginTop: DesignSystem.spacing.xs, + }, score: { flexShrink: 0, - fontSize: 30, - lineHeight: 36, - fontWeight: '700', + ...DesignSystem.text('display'), fontVariant: ['tabular-nums'], }, - track: { height: 5, borderRadius: 3, overflow: 'hidden', backgroundColor: theme.colors.border }, - progress: { height: '100%', borderRadius: 3 }, - summaryBody: { ...DesignSystem.typography.body, color: theme.colors.textSecondary }, + track: { + height: 5, + borderRadius: DesignSystem.radii.pill, + overflow: 'hidden', + backgroundColor: theme.colors.border, + }, + progress: { height: '100%', borderRadius: DesignSystem.radii.pill }, + summaryBody: { ...DesignSystem.text('body', theme.colors.textSecondary) }, metrics: { flexDirection: 'row', borderTopWidth: 1, borderTopColor: theme.colors.divider }, - metric: { flex: 1, paddingTop: 14, gap: 2 }, + metric: { flex: 1, paddingTop: DesignSystem.spacing.l, gap: DesignSystem.spacing.xxs }, metricValue: { - ...DesignSystem.typography.title, - color: theme.colors.text, + ...DesignSystem.text('title', theme.colors.text), fontVariant: ['tabular-nums'], }, - metricLabel: { ...DesignSystem.typography.caption, color: theme.colors.textSecondary }, + metricLabel: { ...DesignSystem.text('caption', theme.colors.textSecondary) }, filters: { - paddingHorizontal: DesignSystem.spacing.m, flexDirection: 'row', flexWrap: 'wrap', gap: DesignSystem.spacing.s, }, filter: { - minHeight: 40, - paddingHorizontal: 14, + minHeight: DesignSystem.layout.touchTarget.minimum, + paddingHorizontal: DesignSystem.spacing.m, alignItems: 'center', justifyContent: 'center', - borderRadius: DesignSystem.borderRadius.small, + borderRadius: DesignSystem.radii.small, borderWidth: 1, borderColor: theme.colors.border, backgroundColor: theme.colors.backgroundElevated, @@ -330,19 +344,15 @@ function createStyles(theme: Theme) { borderColor: theme.colors.primary, backgroundColor: theme.colors.chipBackground, }, - filterLabel: { ...DesignSystem.typography.label, color: theme.colors.textSecondary }, + filterLabel: { ...DesignSystem.text('label', theme.colors.textSecondary) }, filterLabelSelected: { color: theme.colors.primary }, sectionLabel: { - ...DesignSystem.typography.caption, - color: theme.colors.textSecondary, + ...DesignSystem.text('micro', theme.colors.textSecondary), textTransform: 'uppercase', - letterSpacing: 1.2, - paddingHorizontal: DesignSystem.spacing.l, paddingTop: DesignSystem.spacing.s, }, issueRow: { minHeight: 72, - marginHorizontal: DesignSystem.spacing.m, paddingHorizontal: DesignSystem.spacing.m, flexDirection: 'row', alignItems: 'center', @@ -353,14 +363,14 @@ function createStyles(theme: Theme) { issueIcon: { width: 40, height: 40, - borderRadius: 10, + borderRadius: DesignSystem.radii.medium, backgroundColor: theme.colors.backgroundElevated, alignItems: 'center', justifyContent: 'center', }, - issueCopy: { flex: 1, gap: 3 }, - issueTitle: { ...DesignSystem.typography.body, fontWeight: '600', color: theme.colors.text }, - issueSubtitle: { ...DesignSystem.typography.caption, color: theme.colors.textSecondary }, + issueCopy: { flex: 1, gap: DesignSystem.spacing.xs }, + issueTitle: { ...DesignSystem.text('body', theme.colors.text), fontWeight: '600' }, + issueSubtitle: { ...DesignSystem.text('caption', theme.colors.textSecondary) }, centerState: { flex: 1, alignItems: 'center', @@ -374,21 +384,20 @@ function createStyles(theme: Theme) { paddingVertical: DesignSystem.spacing.xxl, gap: DesignSystem.spacing.s, }, - emptyTitle: { ...DesignSystem.typography.title, color: theme.colors.text, textAlign: 'center' }, + emptyTitle: { ...DesignSystem.text('title', theme.colors.text), textAlign: 'center' }, stateText: { - ...DesignSystem.typography.body, - color: theme.colors.textSecondary, + ...DesignSystem.text('body', theme.colors.textSecondary), textAlign: 'center', }, retryButton: { - minHeight: 44, + minHeight: DesignSystem.layout.touchTarget.minimum, paddingHorizontal: DesignSystem.spacing.l, - borderRadius: DesignSystem.borderRadius.small, + borderRadius: DesignSystem.radii.small, alignItems: 'center', justifyContent: 'center', backgroundColor: theme.colors.primary, }, - retryLabel: { ...DesignSystem.typography.label, color: theme.colors.textLight }, + retryLabel: { ...DesignSystem.text('button', theme.colors.onPrimary) }, pressed: { opacity: 0.72 }, }); } diff --git a/src/services/auth/authService.ts b/src/services/auth/authService.ts index c762dc5..db4fff7 100644 --- a/src/services/auth/authService.ts +++ b/src/services/auth/authService.ts @@ -2,7 +2,7 @@ import * as LocalAuthentication from 'expo-local-authentication'; import * as CryptoService from '../crypto/cryptoService'; import * as StorageService from '../storage/storageService'; import { UserMasterKey } from '../../models/User'; -import Constants from 'expo-constants'; +import { isExpoGo as isExpoGoEnvironment } from '../../utils/env'; import Logger from '../../utils/logger'; // State @@ -13,7 +13,7 @@ let lastAuthFailure: AuthFailure | null = null; // own recovery record and could commit the wrong metadata for the other's ciphertext. let isVaultRekeyInProgress = false; -const isExpoGo = Constants.executionEnvironment === 'storeClient'; +const isExpoGo = isExpoGoEnvironment(); export type TBiometryTypes = 'TouchID' | 'FaceID' | 'Biometrics'; export type AuthFailureReason = diff --git a/src/services/crypto/cryptoService.ts b/src/services/crypto/cryptoService.ts index 578bec3..6a0a94d 100644 --- a/src/services/crypto/cryptoService.ts +++ b/src/services/crypto/cryptoService.ts @@ -1,4 +1,5 @@ import * as CryptoJS from 'crypto-js'; +import { Platform } from 'react-native'; import { UserMasterKey } from '../../models/User'; import { isExpoGo } from '../../utils/env'; import Logger from '../../utils/logger'; @@ -18,9 +19,13 @@ let argon2id: ((password: string, salt: string, options: Record) => Promise) | null = null; function loadArgon2(): void { - if (isExpoGo()) { + // The web target is a development-only mock surface: never load a native KDF there, + // and do not report its absence as a misconfigured bundled build. + if (isExpoGo() || Platform.OS === 'web') { argon2id = null; - Logger.info('CryptoService: Expo Go detected. Using PBKDF2 fallback.'); + if (isExpoGo()) { + Logger.info('CryptoService: Expo Go detected. Using PBKDF2 fallback.'); + } return; } @@ -34,8 +39,11 @@ function loadArgon2(): void { argon2id = null; throw new Error('Argon2 module not found or null'); } catch (error) { - Logger.warn( - 'CryptoService: react-native-argon2 not available. PBKDF2 is allowed only for PBKDF2 metadata.', + // A bundled build without the native module silently degrades to the pure-JS PBKDF2 + // fallback, which makes PIN unlock take seconds and prevents legacy vaults from being + // upgraded. Log it loudly so the misconfiguration is visible in release diagnostics. + Logger.error( + 'CryptoService: native Argon2 module unavailable in a non-Expo-Go build; PBKDF2 vaults will stay on the slow JS fallback', error, ); } diff --git a/src/utils/env.ts b/src/utils/env.ts index 212eb2d..29b1418 100644 --- a/src/utils/env.ts +++ b/src/utils/env.ts @@ -1,17 +1,20 @@ import Constants from 'expo-constants'; /** - * Utilities per determinare l'ambiente di esecuzione (Expo Go vs Standalone/Dev Client) - * Basato su Constants.executionEnvironment e presenza di expoGoConfig. + * Utilities per determinare l'ambiente di esecuzione (Expo Go vs Standalone/Dev Client). + * + * Do not use `Constants.expoGoConfig` as an Expo Go signal. expo-constants resolves that + * property from the running manifest: in standalone/EAS builds the embedded update manifest + * has no `metadata` key, so the getter returns the embedded manifest object and the check + * becomes truthy outside Expo Go as well. That false positive made production builds take + * the pure-JS PBKDF2 fallback instead of the native Argon2id KDF. `executionEnvironment` + * and the deprecated `appOwnership` flag are the reliable signals. */ export function isExpoGo(): boolean { try { - // StoreClient indica Expo Go - // Alcune versioni mantengono anche appOwnership==='expo' (deprecato) - const execEnv: any = (Constants as any)?.executionEnvironment; - const expoGoConfig = (Constants as any)?.expoGoConfig; - const appOwnership = (Constants as any)?.appOwnership; - return execEnv === 'storeClient' || !!expoGoConfig || appOwnership === 'expo'; + const execEnv: string | undefined = (Constants as any)?.executionEnvironment; + const appOwnership: string | undefined = (Constants as any)?.appOwnership; + return execEnv === 'storeClient' || appOwnership === 'expo'; } catch { return false; } @@ -19,7 +22,7 @@ export function isExpoGo(): boolean { export function isStandalone(): boolean { try { - const execEnv: any = (Constants as any)?.executionEnvironment; + const execEnv: string | undefined = (Constants as any)?.executionEnvironment; return execEnv === 'standalone'; } catch { return false; diff --git a/src/utils/responsive.ts b/src/utils/responsive.ts index 3f9bf58..b5fad1b 100644 --- a/src/utils/responsive.ts +++ b/src/utils/responsive.ts @@ -1,18 +1,19 @@ -import { Dimensions, Platform, PixelRatio, useWindowDimensions } from 'react-native'; +import { Dimensions, PixelRatio, Platform, useWindowDimensions } from 'react-native'; -/** - * Breakpoints for responsive design - * Based on common device categories - */ -export const BREAKPOINTS = { - phone: 0, - tablet: 768, - tv: 1280, -} as const; +import { BREAKPOINTS, WINDOW_METRICS, type WindowSizeClass } from '../constants/layout'; /** - * Get current window dimensions + * Responsive helpers for the Keysoft design system. + * + * `resolveLayout` is a pure function so window size classes can be unit tested + * without mounting a component. `useResponsiveLayout` is the hook screens use. */ +export { BREAKPOINTS }; +export type { WindowSizeClass }; + +export type DeviceType = 'phone' | 'tablet' | 'tv'; +export type Orientation = 'portrait' | 'landscape'; + interface WindowDimensions { width: number; height: number; @@ -23,117 +24,156 @@ export function getWindowDimensions(): WindowDimensions { return { width, height }; } -/** - * Get current screen dimensions (including status bar, navigation bar, etc.) - */ export function getScreenDimensions(): WindowDimensions { const { width, height } = Dimensions.get('screen'); return { width, height }; } +export function getWindowSizeClass(width: number): WindowSizeClass { + if (width >= BREAKPOINTS.large) return 'large'; + if (width >= BREAKPOINTS.expanded) return 'expanded'; + if (width >= BREAKPOINTS.medium) return 'medium'; + return 'compact'; +} + /** - * Determine device type based on screen width + * Legacy device category. Kept for callers that only need a coarse bucket: + * compact -> phone, medium/expanded -> tablet, large -> tv. */ -export type DeviceType = 'phone' | 'tablet' | 'tv'; +export function getDeviceTypeForWidth(width: number): DeviceType { + if (width >= BREAKPOINTS.large) return 'tv'; + if (width >= BREAKPOINTS.medium) return 'tablet'; + return 'phone'; +} + +export function getDeviceType(): DeviceType { + return getDeviceTypeForWidth(getWindowDimensions().width); +} export interface ResponsiveLayout { width: number; height: number; + fontScale: number; + windowClass: WindowSizeClass; deviceType: DeviceType; + orientation: Orientation; + isCompact: boolean; + isMedium: boolean; + isExpanded: boolean; + isLarge: boolean; + isPhone: boolean; + isTablet: boolean; + isTV: boolean; + isTabletOrLarger: boolean; isLargeScreen: boolean; isLandscape: boolean; + isPortrait: boolean; + /** Short landscape phone: reduces vertical chrome and uses wider layouts. */ + isShortLandscape: boolean; + /** Narrow split-view / foldable window (medium width, landscape). */ + isSplitView: boolean; + /** The user is running with a large accessibility font size. */ + isLargeText: boolean; columns: number; + gridGap: number; + gutter: number; horizontalPadding: number; + contentWidth: number; maxContentWidth: number; + minTouchTarget: number; + headerHeight: number; } -export function useResponsiveLayout(): ResponsiveLayout { - const { width, height } = useWindowDimensions(); - const deviceType: DeviceType = - width >= BREAKPOINTS.tv ? 'tv' : width >= BREAKPOINTS.tablet ? 'tablet' : 'phone'; +/** Pure resolver used by the hook and by tests. */ +export function resolveLayout(width: number, height: number, fontScale = 1): ResponsiveLayout { + const windowClass = getWindowSizeClass(width); + const metrics = WINDOW_METRICS[windowClass]; + const deviceType = getDeviceTypeForWidth(width); + const isLandscape = width > height; + const isLargeText = fontScale >= 1.3; + const minTouchTarget = windowClass === 'compact' ? 44 : 48; return { width, height, + fontScale, + windowClass, deviceType, - isLargeScreen: deviceType !== 'phone', - isLandscape: width > height, - columns: deviceType === 'phone' ? 1 : 2, - horizontalPadding: deviceType === 'phone' ? 16 : 32, - maxContentWidth: deviceType === 'phone' ? width : 1180, + orientation: isLandscape ? 'landscape' : 'portrait', + isCompact: windowClass === 'compact', + isMedium: windowClass === 'medium', + isExpanded: windowClass === 'expanded', + isLarge: windowClass === 'large', + isPhone: deviceType === 'phone', + isTablet: deviceType === 'tablet', + isTV: deviceType === 'tv', + isTabletOrLarger: deviceType !== 'phone', + isLargeScreen: windowClass !== 'compact', + isLandscape, + isPortrait: !isLandscape, + isShortLandscape: isLandscape && height < 480, + isSplitView: isLandscape && width >= BREAKPOINTS.medium && width < BREAKPOINTS.expanded, + isLargeText, + columns: metrics.columns, + gridGap: metrics.itemGap, + gutter: metrics.gutter, + horizontalPadding: metrics.gutter, + contentWidth: Math.min(width, metrics.contentMaxWidth), + maxContentWidth: metrics.contentMaxWidth, + minTouchTarget, + headerHeight: metrics.headerHeight, }; } -export function getDeviceType(): DeviceType { - const { width } = getWindowDimensions(); - - if (width >= BREAKPOINTS.tv) { - return 'tv'; - } - - if (width >= BREAKPOINTS.tablet) { - return 'tablet'; - } - - return 'phone'; +export function useResponsiveLayout(): ResponsiveLayout { + const { width, height, fontScale } = useWindowDimensions(); + return resolveLayout(width, height, fontScale); } /** - * Check if device is a phone + * Pick a value for the current window size class, falling back to `compact`. + * Example: `useResponsiveValue({ compact: 16, medium: 24, expanded: 32 })`. */ +export function useResponsiveValue( + values: { compact: T } & Partial>, +) { + const { windowClass } = useResponsiveLayout(); + return values[windowClass] ?? values.compact; +} + export function isPhone(): boolean { return getDeviceType() === 'phone'; } -/** - * Check if device is a tablet - */ export function isTablet(): boolean { return getDeviceType() === 'tablet'; } -/** - * Check if device is a TV - */ export function isTV(): boolean { return getDeviceType() === 'tv' || Platform.isTV; } -/** - * Check if device is tablet or larger - */ export function isTabletOrLarger(): boolean { const deviceType = getDeviceType(); return deviceType === 'tablet' || deviceType === 'tv'; } -/** - * Get orientation - */ -export type Orientation = 'portrait' | 'landscape'; - export function getOrientation(): Orientation { const { width, height } = getWindowDimensions(); return width > height ? 'landscape' : 'portrait'; } -/** - * Check if orientation is landscape - */ export function isLandscape(): boolean { return getOrientation() === 'landscape'; } -/** - * Check if orientation is portrait - */ export function isPortrait(): boolean { return getOrientation() === 'portrait'; } /** - * Responsive value based on device type - * Returns different values for phone, tablet, and TV + * Legacy responsive value helper. + * Maps phone -> compact, tablet -> medium/expanded, tv -> large. */ export function responsiveValue(values: { phone: T; tablet?: T; tv?: T }): T { const deviceType = getDeviceType(); @@ -148,98 +188,60 @@ export function responsiveValue(values: { phone: T; tablet?: T; tv?: T }): T } /** - * Scale size based on device type - * Useful for fonts, spacing, etc. - * TV usa lo stesso scaling del tablet per consistenza visiva + * Legacy size scaling. The new design system prefers tokens over percentage + * scaling; this helper stays for backward compatibility. */ export function scaleSize(size: number): number { const deviceType = getDeviceType(); switch (deviceType) { case 'tv': - return size * 1.3; // TV usa stesso scaling del tablet case 'tablet': - return size * 1.3; // 30% larger for tablet + return size * 1.3; default: return size; } } -/** - * Get pixel ratio - */ export function getPixelRatio(): number { return PixelRatio.get(); } -/** - * Convert dp to px - */ export function dpToPx(dp: number): number { return PixelRatio.getPixelSizeForLayoutSize(dp); } -/** - * Convert px to dp - */ export function pxToDp(px: number): number { return px / PixelRatio.get(); } -/** - * Get number of columns for grid layout - * TV uses the same two-column layout as tablets for better usability - */ +/** Legacy grid columns helper. The design-system value is `layout.columns`. */ export function getGridColumns(): number { - return responsiveValue({ - phone: 1, - tablet: 2, - tv: 2, // TV usa layout tablet - }); + return responsiveValue({ phone: 1, tablet: 2, tv: 2 }); } -/** - * Get max width for content container - * Prevents content from being too wide on large screens - * TV uses the same width as tablets for better readability - */ +/** Legacy content width helper. The design-system value is `layout.maxContentWidth`. */ export function getMaxContentWidth(): number { const deviceType = getDeviceType(); switch (deviceType) { case 'tv': - return 1180; case 'tablet': return 1180; default: - return Infinity; // No limit for phone + return Infinity; } } -/** - * Get horizontal padding for screen content - * TV usa lo stesso padding del tablet - */ +/** Legacy screen padding helper. The design-system value is `layout.gutter`. */ export function getScreenPadding(): number { - return responsiveValue({ - phone: 16, - tablet: 32, - tv: 32, // TV usa stesso padding del tablet - }); + return responsiveValue({ phone: 16, tablet: 32, tv: 32 }); } -/** - * Check if screen is small (width < 375) - */ export function isSmallScreen(): boolean { - const { width } = getWindowDimensions(); - return width < 375; + return getWindowDimensions().width < 375; } -/** - * Check if screen is large (width >= tablet breakpoint) - */ export function isLargeScreen(): boolean { - const { width } = getWindowDimensions(); - return width >= BREAKPOINTS.tablet; + return getWindowDimensions().width >= BREAKPOINTS.medium; }