Livrare Lot 3 (Frontend): aplicație web, aplicație mobilă Android, extensie browser

- Surse complete web (React/Vite) + mobil (React Native/Expo) + extensie (MV3)
- Documentație de livrare: ghid utilizare, matrice trasabilitate cerințe, raport testare furnizor
- Artefacte binare: imagine Docker didi-frontend:lot3-1.0, APK, extensie v3.2.6 + SHA256SUMS
- Configurare adresă platformă externalizată (build args / .env / config.js)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Top Clossers 2026-07-17 12:33:59 +03:00
commit cec967f953
321 changed files with 80506 additions and 0 deletions

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
node_modules/
dist/
.expo/
android/didi-app-source/android/app/build/
android/didi-app-source/android/.gradle/
android/didi-app-source/android/build/
android/didi-app-source/.env
*.log
.DS_Store

57
README.md Normal file
View file

@ -0,0 +1,57 @@
# DiDi — Lot 3 (Frontend) · Livrare
Platformă digitală inteligentă pentru prevenirea și combaterea dezinformării — **DiDi**
PNRR DIGI150 · Contract de finanțare 11.1.i3.c9 · Contract de furnizare nr. 19
Componentele frontend ale platformei DiDi: **aplicație web**, **aplicație mobilă (Android)**
și **extensie de browser (Chrome/Edge, Manifest V3)**, integrate cu backend-ul platformei
(Lot 1 — AI, Lot 2 — Backend) prin API Gateway (Kong) și IAM (Keycloak, OIDC/OAuth2 + PKCE).
## Structura repo-ului
| Cale | Conținut |
|---|---|
| `web/` | Aplicația web — React 18 + Vite + TypeScript (SPA), imagine nginx |
| `android/didi-app-source/` | Aplicația mobilă — React Native + Expo SDK 54 (include proiectul nativ `android/` cu patch-urile de manifest) |
| `extension/` | Extensia de browser — MV3, vanilla JS |
| `docs/` | Documentația de livrare: 05 Ghid de utilizare · 06 Matrice de trasabilitate a cerințelor · 07 Raport de testare furnizor (md + docx, cu capturi încorporate) |
| `artefacte/` | Livrabile binare: imagine Docker (tar.gz), APK, extensie (zip), `SHA256SUMS.txt`, `README_LIVRARE.md` |
| `docker-compose.local.yml` | Deployment-ul aplicației web pe serverul platformei |
| `build.sh`, `TESTING.md` | Utilitare de build / note de testare |
## Configurarea adresei platformei
Sursele nu conțin adrese hardcodate. Adresa serverului DiDi se setează per componentă:
1. **Web** — la build: `docker build --build-arg VITE_API_URL= --build-arg VITE_KEYCLOAK_URL=https://<ADRESA>/auth -t didi-frontend:local ./web`
(VITE_API_URL gol = same-origin; nginx-ul din imagine proxează `/api`, `/agent-v3`, `/auth`)
2. **Mobil**`android/didi-app-source/.env`: `EXPO_PUBLIC_DIDI_BASE_URL=http://<ADRESA>`
(model în `.env.example`; fișierul `.env` nu se versionează)
3. **Extensie**`extension/config.js` (`DIDI_HOST_HTTP` / `DIDI_HOST_HTTPS`)
Detalii complete de instalare/instanțiere: `artefacte/README_LIVRARE.md`.
## Build rapid
```bash
# Web (imagine Docker)
docker build --build-arg VITE_API_URL= --build-arg VITE_KEYCLOAK_URL=https://<ADRESA>/auth \
-t didi-frontend:local ./web
docker compose -f docker-compose.local.yml up -d
# Mobil (APK release; necesită JDK 17 + Android SDK — vezi docs)
cd android/didi-app-source && cp .env.example .env # completează adresa
cd android && ./gradlew assembleRelease
# Extensie: chrome://extensions → Developer mode → Load unpacked → folderul extension/
```
## Autentificare
Toate cele 3 componente folosesc contul unic al platformei (Keycloak, realm `didi-clients`):
clienți OIDC `didi-web-app`, `didi-mobile-app`, `didi-extension` (public, PKCE S256).
Extensia are ID stabil `blcalchooinkphpnlkkhlklbmgppmnfm` (cheie în manifest) — pe el este
înregistrat redirect-ul OIDC `https://<id>.chromiumapp.org/*`.
---
*Livrare Lot 3 · 17.07.2026*

86
TESTING.md Normal file
View file

@ -0,0 +1,86 @@
# DIDI -- Test Report
Tester: _______________
Data: _______________
---
## 1. Input
### Text
| Ce s-a testat | Rezultat | Observatii |
|---------------|----------|------------|
| | | |
### URL
| Ce s-a testat | Rezultat | Observatii |
|---------------|----------|------------|
| | | |
### Imagine
| Ce s-a testat | Rezultat | Observatii |
|---------------|----------|------------|
| | | |
### Video
| Ce s-a testat | Rezultat | Observatii |
|---------------|----------|------------|
| | | |
### Audio
| Ce s-a testat | Rezultat | Observatii |
|---------------|----------|------------|
| | | |
---
## 2. Core App
### Comportament (timeout, crash, bug)
| Ce s-a testat | Rezultat | Observatii |
|---------------|----------|------------|
| | | |
### Fine-tune (ce trebuie ajustat)
| Ce s-a testat | Rezultat | Observatii |
|---------------|----------|------------|
| | | |
---
## 3. Results
### Manipulation Techniques
| Ce s-a testat | Rezultat | Observatii |
|---------------|----------|------------|
| | | |
### Claims
| Ce s-a testat | Rezultat | Observatii |
|---------------|----------|------------|
| | | |
### Domain
| Ce s-a testat | Rezultat | Observatii |
|---------------|----------|------------|
| | | |
### Verdict
| Ce s-a testat | Rezultat | Observatii |
|---------------|----------|------------|
| | | |
---
## 4. Feedback
| Ce nu merge / ce trebuie schimbat | Prioritate | Detalii |
|------------------------------------|------------|---------|
| | | |

View file

@ -0,0 +1,4 @@
# Adresa platformei DiDi (nginx frontend / gateway). Exemple:
# LAN integrare: http://10.11.10.18
# Productie: https://didi.exemplu.ro
EXPO_PUBLIC_DIDI_BASE_URL=

47
android/didi-app-source/.gitignore vendored Normal file
View file

@ -0,0 +1,47 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# Proiectul nativ /android ESTE versionat în această livrare (conține patch-uri
# manuale de manifest — share intents, cleartext — care s-ar pierde la `expo prebuild`).
/ios
.env
# fișiere locale de build din proiectul nativ
/android/local.properties
/android/app/build/
/android/.gradle/
/android/build/

View file

@ -0,0 +1,30 @@
import React from 'react';
import { View, ActivityIndicator } from 'react-native';
import { StatusBar } from 'expo-status-bar';
import { ShareIntentProvider } from 'expo-share-intent';
import { useFonts } from 'expo-font';
import AppNavigator from './src/navigation/AppNavigator';
import { COLORS } from './src/theme/colors';
export default function App() {
const [fontsLoaded] = useFonts({
'Merriweather-Regular': require('./assets/fonts/Merriweather-Regular.ttf'),
'Merriweather-Italic': require('./assets/fonts/Merriweather-Italic.ttf'),
'Merriweather-Bold': require('./assets/fonts/Merriweather-Bold.ttf'),
});
if (!fontsLoaded) {
return (
<View style={{ flex: 1, backgroundColor: COLORS.bg.primary, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator color={COLORS.brand.primaryLight} size="large" />
</View>
);
}
return (
<ShareIntentProvider>
<StatusBar style="light" />
<AppNavigator />
</ShareIntentProvider>
);
}

View file

@ -0,0 +1,16 @@
# OSX
#
.DS_Store
# Android/IntelliJ
#
build/
.idea
.gradle
local.properties
*.iml
*.hprof
.cxx/
# Bundle artifacts
*.jsbundle

View file

@ -0,0 +1,182 @@
apply plugin: "com.android.application"
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "com.facebook.react"
def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
/**
* This is the configuration block to customize your React Native Android app.
* By default you don't need to apply any configuration, just uncomment the lines you need.
*/
react {
entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc"
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean()
// Use Expo CLI to bundle the app, this ensures the Metro config
// works correctly with Expo projects.
cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
bundleCommand = "export:embed"
/* Folders */
// The root of your project, i.e. where "package.json" lives. Default is '../..'
// root = file("../../")
// The folder where the react-native NPM package is. Default is ../../node_modules/react-native
// reactNativeDir = file("../../node_modules/react-native")
// The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen
// codegenDir = file("../../node_modules/@react-native/codegen")
/* Variants */
// The list of variants to that are debuggable. For those we're going to
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
// debuggableVariants = ["liteDebug", "prodDebug"]
/* Bundling */
// A list containing the node command and its flags. Default is just 'node'.
// nodeExecutableAndArgs = ["node"]
//
// The path to the CLI configuration file. Default is empty.
// bundleConfig = file(../rn-cli.config.js)
//
// The name of the generated asset file containing your JS bundle
// bundleAssetName = "MyApplication.android.bundle"
//
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
// entryFile = file("../js/MyApplication.android.js")
//
// A list of extra flags to pass to the 'bundle' commands.
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
// extraPackagerArgs = []
/* Hermes Commands */
// The hermes compiler command to run. By default it is 'hermesc'
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
//
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
// hermesFlags = ["-O", "-output-source-map"]
/* Autolinking */
autolinkLibrariesWithApp()
}
/**
* Set this to true in release builds to optimize the app using [R8](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization).
*/
def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean()
/**
* The preferred build flavor of JavaScriptCore (JSC)
*
* For example, to use the international variant, you can use:
* `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
*
* The international variant includes ICU i18n library and necessary data
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
* give correct results when using with locales other than en-US. Note that
* this variant is about 6MiB larger per architecture than default.
*/
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
android {
ndkVersion rootProject.ext.ndkVersion
buildToolsVersion rootProject.ext.buildToolsVersion
compileSdk rootProject.ext.compileSdkVersion
namespace 'eu.didi365.mobile'
defaultConfig {
applicationId 'eu.didi365.mobile'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0.0"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
}
signingConfigs {
debug {
storeFile file('debug.keystore')
storePassword 'android'
keyAlias 'androiddebugkey'
keyPassword 'android'
}
}
buildTypes {
debug {
signingConfig signingConfigs.debug
}
release {
// Caution! In production, you need to generate your own keystore file.
// see https://reactnative.dev/docs/signed-apk-android.
signingConfig signingConfigs.debug
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
shrinkResources enableShrinkResources.toBoolean()
minifyEnabled enableMinifyInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true'
crunchPngs enablePngCrunchInRelease.toBoolean()
}
}
packagingOptions {
jniLibs {
def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false'
useLegacyPackaging enableLegacyPackaging.toBoolean()
}
}
androidResources {
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
// Apply static values from `gradle.properties` to the `android.packagingOptions`
// Accepts values in comma delimited lists, example:
// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini
["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop ->
// Split option: 'foo,bar' -> ['foo', 'bar']
def options = (findProperty("android.packagingOptions.$prop") ?: "").split(",");
// Trim all elements in place.
for (i in 0..<options.size()) options[i] = options[i].trim();
// `[] - ""` is essentially `[""].filter(Boolean)` removing all empty strings.
options -= ""
if (options.length > 0) {
println "android.packagingOptions.$prop += $options ($options.length)"
// Ex: android.packagingOptions.pickFirsts += '**/SCCS/**'
options.each {
android.packagingOptions[prop] += it
}
}
}
dependencies {
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
if (isGifEnabled) {
// For animated gif support
implementation("com.facebook.fresco:animated-gif:${expoLibs.versions.fresco.get()}")
}
if (isWebpEnabled) {
// For webp support
implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}")
if (isWebpAnimatedEnabled) {
// Animated webp support
implementation("com.facebook.fresco:animated-webp:${expoLibs.versions.fresco.get()}")
}
}
if (hermesEnabled.toBoolean()) {
implementation("com.facebook.react:hermes-android")
} else {
implementation jscFlavor
}
}

Binary file not shown.

View file

@ -0,0 +1,14 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the proguardFiles
# directive in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# react-native-reanimated
-keep class com.swmansion.reanimated.** { *; }
-keep class com.facebook.react.turbomodule.** { *; }
# Add any project specific keep options here:

View file

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
</manifest>

View file

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
</manifest>

View file

@ -0,0 +1,43 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<queries>
<intent>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https"/>
</intent>
</queries>
<application android:usesCleartextTraffic="true" android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules">
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="didi"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND"/>
<data android:mimeType="text/plain"/>
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND"/>
<data android:mimeType="image/*"/>
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,61 @@
package eu.didi365.mobile
import android.os.Build
import android.os.Bundle
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
import com.facebook.react.defaults.DefaultReactActivityDelegate
import expo.modules.ReactActivityDelegateWrapper
class MainActivity : ReactActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// Set the theme to AppTheme BEFORE onCreate to support
// coloring the background, status bar, and navigation bar.
// This is required for expo-splash-screen.
setTheme(R.style.AppTheme);
super.onCreate(null)
}
/**
* Returns the name of the main component registered from JavaScript. This is used to schedule
* rendering of the component.
*/
override fun getMainComponentName(): String = "main"
/**
* Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
*/
override fun createReactActivityDelegate(): ReactActivityDelegate {
return ReactActivityDelegateWrapper(
this,
BuildConfig.IS_NEW_ARCHITECTURE_ENABLED,
object : DefaultReactActivityDelegate(
this,
mainComponentName,
fabricEnabled
){})
}
/**
* Align the back button behavior with Android S
* where moving root activities to background instead of finishing activities.
* @see <a href="https://developer.android.com/reference/android/app/Activity#onBackPressed()">onBackPressed</a>
*/
override fun invokeDefaultOnBackPressed() {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
if (!moveTaskToBack(false)) {
// For non-root activities, use the default implementation to finish them.
super.invokeDefaultOnBackPressed()
}
return
}
// Use the default back button implementation on Android S
// because it's doing more than [Activity.moveTaskToBack] in fact.
super.invokeDefaultOnBackPressed()
}
}

View file

@ -0,0 +1,56 @@
package eu.didi365.mobile
import android.app.Application
import android.content.res.Configuration
import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
import com.facebook.react.ReactNativeHost
import com.facebook.react.ReactPackage
import com.facebook.react.ReactHost
import com.facebook.react.common.ReleaseLevel
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
import com.facebook.react.defaults.DefaultReactNativeHost
import expo.modules.ApplicationLifecycleDispatcher
import expo.modules.ReactNativeHostWrapper
class MainApplication : Application(), ReactApplication {
override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(
this,
object : DefaultReactNativeHost(this) {
override fun getPackages(): List<ReactPackage> =
PackageList(this).packages.apply {
// Packages that cannot be autolinked yet can be added manually here, for example:
// add(MyReactNativePackage())
}
override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"
override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
}
)
override val reactHost: ReactHost
get() = ReactNativeHostWrapper.createReactHost(applicationContext, reactNativeHost)
override fun onCreate() {
super.onCreate()
DefaultNewArchitectureEntryPoint.releaseLevel = try {
ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase())
} catch (e: IllegalArgumentException) {
ReleaseLevel.STABLE
}
loadReactNative(this)
ApplicationLifecycleDispatcher.onApplicationCreate(this)
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

View file

@ -0,0 +1,6 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splashscreen_background"/>
<item>
<bitmap android:gravity="center" android:src="@drawable/splashscreen_logo"/>
</item>
</layer-list>

View file

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2014 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material"
android:insetRight="@dimen/abc_edit_text_inset_horizontal_material"
android:insetTop="@dimen/abc_edit_text_inset_top_material"
android:insetBottom="@dimen/abc_edit_text_inset_bottom_material"
>
<selector>
<!--
This file is a copy of abc_edit_text_material (https://bit.ly/3k8fX7I).
The item below with state_pressed="false" and state_focused="false" causes a NullPointerException.
NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)'
<item android:state_pressed="false" android:state_focused="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
For more info, see https://bit.ly/3CdLStv (react-native/pull/29452) and https://bit.ly/3nxOMoR.
-->
<item android:state_enabled="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
<item android:drawable="@drawable/abc_textfield_activated_mtrl_alpha"/>
</selector>
</inset>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/iconBackground"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/iconBackground"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View file

@ -0,0 +1 @@
<resources/>

View file

@ -0,0 +1,6 @@
<resources>
<color name="splashscreen_background">#060010</color>
<color name="iconBackground">#0d1424</color>
<color name="colorPrimary">#023c69</color>
<color name="colorPrimaryDark">#060010</color>
</resources>

View file

@ -0,0 +1,5 @@
<resources>
<string name="app_name">didi</string>
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
</resources>

View file

@ -0,0 +1,11 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:enforceNavigationBarContrast" tools:targetApi="29">true</item>
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
<item name="colorPrimary">@color/colorPrimary</item>
<item name="android:statusBarColor">#060010</item>
</style>
<style name="Theme.App.SplashScreen" parent="AppTheme">
<item name="android:windowBackground">@drawable/ic_launcher_background</item>
</style>
</resources>

View file

@ -0,0 +1,24 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath('com.android.tools.build:gradle')
classpath('com.facebook.react:react-native-gradle-plugin')
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
}
}
allprojects {
repositories {
google()
mavenCentral()
maven { url 'https://www.jitpack.io' }
}
}
apply plugin: "expo-root-project"
apply plugin: "com.facebook.react.rootproject"

View file

@ -0,0 +1,67 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
org.gradle.jvmargs=-Xmx6144m -XX:MaxMetaspaceSize=1024m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Enable AAPT2 PNG crunching
android.enablePngCrunchInReleaseBuilds=true
# Use this property to specify which architecture you want to build.
# You can also override it from the CLI using
# ./gradlew <task> -PreactNativeArchitectures=x86_64
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
# Use this property to enable support to the new architecture.
# This will allow you to use TurboModules and the Fabric render in
# your application. You should enable this flag either if you want
# to write custom TurboModules/Fabric components OR use libraries that
# are providing them.
newArchEnabled=true
# Use this property to enable or disable the Hermes JS engine.
# If set to false, you will be using JSC instead.
hermesEnabled=true
# Use this property to enable edge-to-edge display support.
# This allows your app to draw behind system bars for an immersive UI.
# Note: Only works with ReactActivity and should not be used with custom Activity.
edgeToEdgeEnabled=true
# Enable GIF support in React Native images (~200 B increase)
expo.gif.enabled=true
# Enable webp support in React Native images (~85 KB increase)
expo.webp.enabled=true
# Enable animated webp support (~3.4 MB increase)
# Disabled by default because iOS doesn't support animated webp
expo.webp.animated=false
# Enable network inspector
EX_DEV_CLIENT_NETWORK_INSPECTOR=true
# Use legacy packaging to compress native libraries in the resulting APK.
expo.useLegacyPackaging=false
# Specifies whether the app is configured to use edge-to-edge via the app config or plugin
# WARNING: This property has been deprecated and will be removed in Expo SDK 55. Use `edgeToEdgeEnabled` or `react.edgeToEdgeEnabled` to determine whether the project is using edge-to-edge.
expo.edgeToEdgeEnabled=true
# JDK-ul (17) se setează prin variabila de mediu JAVA_HOME sau, opțional, aici:
# org.gradle.java.home=/cale/catre/jdk-17

Binary file not shown.

View file

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
android/didi-app-source/android/gradlew vendored Executable file
View file

@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

View file

@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View file

@ -0,0 +1,39 @@
pluginManagement {
def reactNativeGradlePlugin = new File(
providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })")
}.standardOutput.asText.get().trim()
).getParentFile().absolutePath
includeBuild(reactNativeGradlePlugin)
def expoPluginsPath = new File(
providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })")
}.standardOutput.asText.get().trim(),
"../android/expo-gradle-plugin"
).absolutePath
includeBuild(expoPluginsPath)
}
plugins {
id("com.facebook.react.settings")
id("expo-autolinking-settings")
}
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
ex.autolinkLibrariesFromCommand()
} else {
ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand)
}
}
expoAutolinking.useExpoModules()
rootProject.name = 'didi'
expoAutolinking.useExpoVersionCatalog()
include ':app'
includeBuild(expoAutolinking.reactNativeGradlePlugin)

View file

@ -0,0 +1,49 @@
{
"expo": {
"name": "didi",
"slug": "didi-app",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "dark",
"scheme": "didi",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#060010"
},
"ios": {
"supportsTablet": true,
"bundleIdentifier": "eu.didi365.mobile"
},
"android": {
"package": "eu.didi365.mobile",
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#0d1424"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false,
"usesCleartextTraffic": true
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-secure-store",
"expo-web-browser",
"expo-notifications",
[
"expo-share-intent",
{
"androidIntentFilters": [
"text/plain",
"image/*"
]
}
],
"expo-font"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View file

@ -0,0 +1,9 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
// react-native-worklets/plugin is required by react-native-reanimated v4 and
// MUST be listed last.
plugins: ['react-native-worklets/plugin'],
};
};

View file

@ -0,0 +1,8 @@
import { registerRootComponent } from 'expo';
import App from './App';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);

12173
android/didi-app-source/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,51 @@
{
"name": "didi-app",
"version": "1.0.0",
"main": "index.ts",
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web"
},
"dependencies": {
"@expo/metro-runtime": "~6.1.2",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/native": "^7.1.28",
"@react-navigation/native-stack": "^7.13.0",
"axios": "^1.13.5",
"buffer": "^6.0.3",
"expo": "~54.0.33",
"expo-auth-session": "~7.0.10",
"expo-av": "~16.0.8",
"expo-crypto": "~15.0.8",
"expo-document-picker": "~14.0.8",
"expo-font": "~14.0.11",
"expo-image-picker": "~17.0.10",
"expo-linear-gradient": "~15.0.8",
"expo-linking": "~8.0.11",
"expo-notifications": "~0.32.16",
"expo-secure-store": "~15.0.8",
"expo-share-intent": "^5.1.1",
"expo-status-bar": "~3.0.9",
"expo-web-browser": "~15.0.10",
"lucide-react-native": "^1.14.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "15.12.1",
"react-native-web": "^0.21.0",
"react-native-webview": "13.15.0",
"zustand": "^5.0.11"
},
"devDependencies": {
"@types/react": "~19.1.0",
"babel-preset-expo": "^55.0.21",
"sharp": "^0.34.5",
"typescript": "~5.9.2"
},
"private": true
}

View file

@ -0,0 +1,80 @@
const sharp = require('sharp');
const path = require('path');
const ASSETS = path.join(__dirname, '..', 'assets');
function gradient(id) {
return `<linearGradient id="${id}" x1="0" y1="1" x2="1" y2="0">
<stop offset="0%" stop-color="#0A0014"/>
<stop offset="100%" stop-color="#2D1066"/>
</linearGradient>`;
}
// Full square icon (iOS + fallback)
function makeIcon(size) {
const ts = Math.round(size * 0.32);
const cx = size / 2;
const cy = size / 2 + ts * 0.1;
return `<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
<defs>${gradient('bg')}</defs>
<rect width="${size}" height="${size}" fill="url(#bg)"/>
<text x="${cx}" y="${cy}"
font-family="Arial Rounded MT Bold, Verdana, Arial, sans-serif"
font-size="${ts}" font-weight="700" fill="white"
text-anchor="middle" dominant-baseline="central"
letter-spacing="4">didi</text>
</svg>`;
}
// Adaptive foreground: FULL SQUARE gradient + text (no circle!)
// Android handles the masking (circle, squircle, etc.)
function makeAdaptive(size) {
const ts = Math.round(size * 0.25);
const cx = size / 2;
const cy = size / 2 + ts * 0.1;
return `<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
<defs>${gradient('fg')}</defs>
<rect width="${size}" height="${size}" fill="url(#fg)"/>
<text x="${cx}" y="${cy}"
font-family="Arial Rounded MT Bold, Verdana, Arial, sans-serif"
font-size="${ts}" font-weight="700" fill="white"
text-anchor="middle" dominant-baseline="central"
letter-spacing="3">didi</text>
</svg>`;
}
// Favicon
function makeFavicon(size) {
const ts = Math.round(size * 0.40);
return `<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
<defs>${gradient('fv')}</defs>
<rect width="${size}" height="${size}" rx="${size * 0.15}" fill="url(#fv)"/>
<text x="${size/2}" y="${size/2 + ts * 0.08}"
font-family="Arial Rounded MT Bold, Verdana, Arial, sans-serif"
font-size="${ts}" font-weight="700" fill="white"
text-anchor="middle" dominant-baseline="central"
letter-spacing="1">didi</text>
</svg>`;
}
async function main() {
await sharp(Buffer.from(makeIcon(1024)))
.png().toFile(path.join(ASSETS, 'icon.png'));
console.log('icon.png');
await sharp(Buffer.from(makeAdaptive(1024)))
.png().toFile(path.join(ASSETS, 'adaptive-icon.png'));
console.log('adaptive-icon.png');
await sharp(Buffer.from(makeIcon(1024)))
.png().toFile(path.join(ASSETS, 'splash-icon.png'));
console.log('splash-icon.png');
await sharp(Buffer.from(makeFavicon(256)))
.resize(48, 48).png().toFile(path.join(ASSETS, 'favicon.png'));
console.log('favicon.png');
console.log('Done!');
}
main().catch(console.error);

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

View file

@ -0,0 +1,136 @@
import { skillsApi } from './axios';
import { TIMEOUTS } from './config';
import type {
UploadResponse,
AnalysisSession,
V3Response,
V3QueueStatus,
V3AsyncSubmitData,
} from '../types/analysis';
export async function uploadMedia(
fileUri: string,
fileName: string,
fileType: string,
userId: string
): Promise<string> {
const formData = new FormData();
formData.append('file', {
uri: fileUri,
name: fileName,
type: fileType,
} as any);
formData.append('user_id', userId);
const response = await skillsApi.post<V3Response<UploadResponse['data']>>('media/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: TIMEOUTS.UPLOAD,
});
const data = response.data?.data;
return data?.public_url || '';
}
const ALLOWED_SKILLS = ['techniques', 'claims', 'ai-tampered', 'domain', 'context', 'source-assessment'];
export async function analyzeSkill(
skill: string,
body: { text?: string; media_url?: string; user_id?: string; [key: string]: any }
): Promise<AnalysisSession> {
if (!ALLOWED_SKILLS.includes(skill)) {
throw new Error(`Invalid skill: ${skill}`);
}
const endpoint = body.media_url
? `${skill}/analyze-media`
: `${skill}/analyze`;
const response = await skillsApi.post<V3Response>(
endpoint,
body,
{ timeout: TIMEOUTS.SKILL }
);
return response.data?.data || response.data as any;
}
export async function analyzePipeline(
body: { text?: string; media_url?: string; media_type?: string; user_id?: string; plan_type?: number; [key: string]: any }
): Promise<V3Response> {
const endpoint = body.media_url
? 'pipeline/analyze-media'
: 'pipeline/analyze';
const response = await skillsApi.post<V3Response>(
endpoint,
body,
{ timeout: TIMEOUTS.SKILL }
);
return response.data;
}
export async function submitPipelineAsync(
body: Record<string, any>
): Promise<{ session_id: string; async: boolean; data: V3AsyncSubmitData | AnalysisSession }> {
const response = await skillsApi.post<V3Response<V3AsyncSubmitData>>(
'pipeline/analyze-async',
body,
{ timeout: TIMEOUTS.SKILL }
);
const res = response.data;
// Async path: 202 — session_id is inside data
if (res.async && res.data?.session_id) {
return { session_id: res.data.session_id, async: true, data: res.data };
}
// Sync fallback: RabbitMQ down — full result returned directly
return { session_id: (res.data as any)?.session_id || '', async: false, data: res.data };
}
export async function submitSkillAsync(
skill: string,
body: Record<string, any>
): Promise<{ session_id: string; async: boolean; data: V3AsyncSubmitData | AnalysisSession }> {
if (!ALLOWED_SKILLS.includes(skill)) {
throw new Error(`Invalid skill: ${skill}`);
}
const response = await skillsApi.post<V3Response<V3AsyncSubmitData>>(
`${skill}/analyze-async`,
body,
{ timeout: TIMEOUTS.SKILL }
);
const res = response.data;
// Async path: session_id is inside data
if (res.async && res.data?.session_id) {
return { session_id: res.data.session_id, async: true, data: res.data };
}
// Sync fallback: full result returned directly
return { session_id: (res.data as any)?.session_id || '', async: false, data: res.data };
}
export async function pollQueueStatus(sessionId: string, signal?: AbortSignal): Promise<V3QueueStatus> {
const response = await skillsApi.get<V3Response<AnalysisSession>>(
`pipeline/${encodeURIComponent(sessionId)}/queue-status`,
{ signal }
);
const session = response.data?.data;
const queue = session?._queue;
return {
session,
progress: queue?.progress || 0,
total_components: queue?.total_components || 5,
completed_components: queue?.completed_components || [],
status: session?.status || 'pending',
};
}
export async function getPipelineResult(sessionId: string, signal?: AbortSignal): Promise<AnalysisSession> {
const response = await skillsApi.get<V3Response>(
`pipeline/${encodeURIComponent(sessionId)}/result`,
{ signal }
);
return response.data?.data || response.data as any;
}

View file

@ -0,0 +1,41 @@
import axios from 'axios';
import { AUTH_CONFIG, getRedirectUri } from './config';
import { mainApi } from './axios';
import { TokenResponse, UserProfile } from '../types/auth';
export async function exchangeCodeForTokens(
code: string,
codeVerifier: string
): Promise<TokenResponse> {
const params = new URLSearchParams({
grant_type: 'authorization_code',
client_id: AUTH_CONFIG.CLIENT_ID,
code,
redirect_uri: getRedirectUri(),
code_verifier: codeVerifier,
});
const response = await axios.post(AUTH_CONFIG.TOKEN_URL, params.toString(), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
timeout: 30000,
});
return response.data;
}
export async function fetchProfile(): Promise<UserProfile> {
const response = await mainApi.get('auth/me');
// API returns { success: true, data: { ... } }
return response.data?.data || response.data;
}
export async function fetchSubscriptionUsage() {
const response = await mainApi.get('subscriptions/usage');
// API returns { success: true, data: { credits, plan, subscription } }
return response.data?.data || response.data;
}
export async function fetchCredits(): Promise<{ credits_remained: number; credits_spent: number }> {
const response = await mainApi.get('auth/credits');
return response.data?.data || response.data;
}

View file

@ -0,0 +1,62 @@
import axios from 'axios';
import { Platform } from 'react-native';
import { API_CONFIG } from './config';
import { useAuthStore } from '../store/authStore';
const USER_AGENT =
'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 DidiApp/1.0';
// Browser forbids setting User-Agent — only set on native
const nativeHeaders = Platform.OS !== 'web' ? { 'User-Agent': USER_AGENT } : {};
if (Platform.OS !== 'web') {
axios.defaults.headers.common['User-Agent'] = USER_AGENT;
}
export const skillsApi = axios.create({
baseURL: API_CONFIG.SKILLS_API,
timeout: 300000,
headers: nativeHeaders,
});
export const mainApi = axios.create({
baseURL: API_CONFIG.MAIN_API,
timeout: 30000,
headers: nativeHeaders,
});
// Request interceptor - attach token & auto-refresh
const attachToken = async (config: any) => {
const { accessToken, expiresAt, refreshAccessToken } = useAuthStore.getState();
if (expiresAt && Date.now() > expiresAt - 30000) {
const newToken = await refreshAccessToken();
if (newToken) {
config.headers.Authorization = `Bearer ${newToken}`;
}
} else if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
};
// Response interceptor - retry on 401 (max once to prevent infinite loops)
const handle401 = async (error: any) => {
const config = error.config;
if (error.response?.status === 401 && !config?._retried) {
config._retried = true;
const newToken = await useAuthStore.getState().refreshAccessToken();
if (newToken) {
config.headers.Authorization = `Bearer ${newToken}`;
return axios.request(config);
}
}
return Promise.reject(error);
};
skillsApi.interceptors.request.use(attachToken);
skillsApi.interceptors.response.use((r) => r, handle401);
mainApi.interceptors.request.use(attachToken);
mainApi.interceptors.response.use((r) => r, handle401);

View file

@ -0,0 +1,58 @@
// Adresa platformei DiDi se setează EXCLUSIV la build, prin variabila de mediu
// EXPO_PUBLIC_DIDI_BASE_URL (fișierul .env din rădăcina proiectului — vezi .env.example).
// Toate endpoint-urile (auth prin prefixul /auth al Keycloak, API-uri prin gateway) derivă
// din această singură valoare; nu hardcoda adrese în cod.
const DIDI_BASE_URL = (process.env.EXPO_PUBLIC_DIDI_BASE_URL ?? '').replace(/\/+$/, '');
if (!DIDI_BASE_URL) {
console.error('[CONFIG] EXPO_PUBLIC_DIDI_BASE_URL nu este setat — vezi .env.example');
}
const AUTH_BASE_URL = `${DIDI_BASE_URL}/auth`;
const KEYCLOAK_REALM = 'didi-clients';
const REALM_BASE = `${AUTH_BASE_URL}/realms/${KEYCLOAK_REALM}`;
const OIDC_BASE = `${REALM_BASE}/protocol/openid-connect`;
export const AUTH_CONFIG = {
AUTH_BASE_URL,
KEYCLOAK_REALM,
CLIENT_ID: 'didi-mobile-app',
REDIRECT_URI: 'didi://callback',
REDIRECT_URI_WEB: typeof window !== 'undefined' && typeof window.location !== 'undefined'
? `${window.location.origin}/auth/callback`
: 'http://localhost:8081/auth/callback',
SCOPES: 'openid profile email roles',
AUTH_URL: `${OIDC_BASE}/auth`,
TOKEN_URL: `${OIDC_BASE}/token`,
REGISTER_URL: `${OIDC_BASE}/registrations`,
USERINFO_URL: `${OIDC_BASE}/userinfo`,
LOGOUT_URL: `${OIDC_BASE}/logout`,
PASSWORD_URL: `${REALM_BASE}/account/credentials/password`,
};
import { Platform } from 'react-native';
export function getRedirectUri(): string {
return Platform.OS === 'web' ? AUTH_CONFIG.REDIRECT_URI_WEB : AUTH_CONFIG.REDIRECT_URI;
}
export const API_CONFIG = {
MAIN_API: `${DIDI_BASE_URL}/api/`,
MAIN_API_V1: `${DIDI_BASE_URL}/api/v1/`,
SKILLS_API: `${DIDI_BASE_URL}/agent-v3/api/v3/`,
SCRAPER_API: `${DIDI_BASE_URL}/scraper/`,
};
export const TIMEOUTS = {
AUTH: 30000,
PIPELINE_TEXT: 120000,
PIPELINE_URL: 120000,
PIPELINE_MEDIA: 300000,
SKILL: 300000,
UPLOAD: 60000,
SCRAPER: 120000,
};
export const POLL_CONFIG = {
INTERVAL: 2500,
MAX_DURATION: 600000,
};

View file

@ -0,0 +1,42 @@
import { skillsApi } from './axios';
import type {
AnalysisSession,
V3HistoryResponse,
V3Response,
} from '../types/analysis';
export async function getHistory(
userId: string,
page: number = 1,
limit: number = 20
): Promise<{ items: AnalysisSession[]; pagination: V3HistoryResponse['data']['pagination'] }> {
const params = new URLSearchParams({
user_id: userId,
page: String(page),
limit: String(limit),
});
const response = await skillsApi.get<V3HistoryResponse>(
`pipeline/history?${params.toString()}`
);
const data = response.data?.data || response.data;
return {
items: Array.isArray((data as any)?.items) ? (data as any).items : [],
pagination: (data as any)?.pagination || { page, limit, total: 0, pages: 0 },
};
}
export async function getHistoryDetail(
sessionId: string,
userId: string
): Promise<AnalysisSession> {
const response = await skillsApi.get<V3Response>(
`pipeline/history/${encodeURIComponent(sessionId)}?user_id=${encodeURIComponent(userId)}`
);
return response.data?.data || response.data as any;
}
export async function deleteHistoryItem(sessionId: string, userId: string): Promise<void> {
await skillsApi.delete(
`pipeline/history/${encodeURIComponent(sessionId)}?user_id=${encodeURIComponent(userId)}`
);
}

View file

@ -0,0 +1,48 @@
import { skillsApi } from './axios';
import type { TechniqueDefinition } from '../types/analysis';
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
let cache: TechniqueDefinition[] | null = null;
let cacheTimestamp = 0;
let pendingRequest: Promise<TechniqueDefinition[]> | null = null;
export async function getTechniqueDefinitions(): Promise<TechniqueDefinition[]> {
// Return cache if fresh
if (cache && Date.now() - cacheTimestamp < CACHE_TTL) {
return cache;
}
// Deduplicate concurrent requests
if (pendingRequest) return pendingRequest;
pendingRequest = (async () => {
try {
const response = await skillsApi.get<{ success: boolean; data: TechniqueDefinition[] }>(
'techniques/definitions',
{ timeout: 15000 }
);
const defs = response.data?.data || [];
cache = defs;
cacheTimestamp = Date.now();
return defs;
} catch {
// If endpoint is down, return cached data or empty — zero errors
return cache || [];
} finally {
pendingRequest = null;
}
})();
return pendingRequest;
}
/** Build a lookup map: technique_name → definition */
export async function getTechniqueDefinitionsMap(): Promise<Map<string, TechniqueDefinition>> {
const defs = await getTechniqueDefinitions();
const map = new Map<string, TechniqueDefinition>();
for (const d of defs) {
map.set(d.technique_name, d);
}
return map;
}

View file

@ -0,0 +1,105 @@
import React from 'react';
import { View, TouchableOpacity, Text, StyleSheet, ViewStyle } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { COLORS, SPACING, RADIUS, FONT_SIZES, FONT_WEIGHTS, LAYOUT } from '../theme/colors';
interface GlassCardProps {
title: string;
subtitle: string;
iconLabel?: string;
iconColors?: readonly [string, string];
onPress?: () => void;
style?: ViewStyle;
borderColor?: string;
}
export default function GlassCard({
title,
subtitle,
iconLabel,
iconColors,
onPress,
style,
borderColor,
}: GlassCardProps) {
const Wrapper = onPress ? TouchableOpacity : View;
return (
<Wrapper
activeOpacity={0.7}
onPress={onPress}
accessibilityRole={onPress ? 'button' : undefined}
accessibilityLabel={onPress ? `${title}. ${subtitle}` : undefined}
style={[
styles.card,
borderColor ? { borderColor } : undefined,
style,
]}
>
<View style={styles.content}>
{iconLabel && (
<LinearGradient
colors={iconColors ? [...iconColors] as [string, string] : [COLORS.brand.primary, COLORS.brand.accentMagenta]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.iconCircle}
>
<Text style={styles.iconText}>{iconLabel}</Text>
</LinearGradient>
)}
<View style={styles.textContainer}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.subtitle}>{subtitle}</Text>
</View>
{onPress && <Text style={styles.arrow}></Text>}
</View>
</Wrapper>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: COLORS.bg.card,
borderWidth: 1,
borderColor: COLORS.border.accent,
borderRadius: RADIUS.xxl,
padding: SPACING.lg,
marginBottom: SPACING.sm + 4,
},
content: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
iconCircle: {
width: LAYOUT.ICON_CIRCLE_SIZE,
height: LAYOUT.ICON_CIRCLE_SIZE,
borderRadius: RADIUS.full,
justifyContent: 'center',
alignItems: 'center',
marginRight: SPACING.md,
},
iconText: {
color: COLORS.text.primary,
fontSize: FONT_SIZES.lg,
fontWeight: FONT_WEIGHTS.bold,
},
textContainer: {
flex: 1,
},
title: {
color: COLORS.text.primary,
fontSize: FONT_SIZES.base,
fontWeight: FONT_WEIGHTS.bold,
},
subtitle: {
color: COLORS.text.secondary,
fontSize: FONT_SIZES.sm,
marginTop: 2,
},
arrow: {
color: COLORS.text.hint,
fontSize: FONT_SIZES.xxl,
fontWeight: FONT_WEIGHTS.bold,
},
});

View file

@ -0,0 +1,309 @@
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator } from 'react-native';
import {
AlertCircle, Cpu, ShieldCheck, ShieldAlert, AlertOctagon, AlertTriangle,
CheckSquare, HelpCircle, FileText, Image as ImageIcon, Music, Video, Globe, Type,
} from 'lucide-react-native';
import type { LucideIcon } from 'lucide-react-native';
import {
COLORS,
SPACING,
RADIUS,
FONT_SIZES,
FONT_WEIGHTS,
ACCENT,
getBadgeBgColor,
} from '../theme/colors';
import type { AnalysisSession } from '../types/analysis';
import { useTranslation, getTranslation, enumLabel } from '../i18n';
interface RunningItemProgress {
progress: number;
completedComponents: string[];
}
interface Props {
item: AnalysisSession;
onPress: () => void;
onLongPress: () => void;
progress?: RunningItemProgress;
}
function formatRelativeTime(dateStr: string): string {
const { t } = getTranslation();
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return t('justNow');
if (diffMin < 60) return t('minutesAgo', { min: diffMin });
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return t('hoursAgo', { hr: diffHr });
const diffDay = Math.floor(diffHr / 24);
if (diffDay < 7) return t('daysAgo', { day: diffDay });
return date.toLocaleDateString();
}
const TYPE_ICON: Record<string, LucideIcon> = {
text: Type,
image: ImageIcon,
audio: Music,
video: Video,
url: Globe,
};
interface CardSummary {
badgeScore: number;
label: string;
badgeColor: string;
badgeIcon: LucideIcon;
}
/** Decide what to show on the right side of the card based on session type. */
function summarizeSession(item: AnalysisSession, t: (k: any) => string, isRo: boolean): CardSummary {
const componentsRun = item.components_run || [];
const isSingle = componentsRun.length === 1;
const single = isSingle ? componentsRun[0] : null;
if (single === 'domain' || single === 'source_assessment') {
const trust = (item as any).source_trust_score ?? 0;
return {
badgeScore: trust,
label: (item as any).source_verdict
? enumLabel((item as any).source_verdict)
: t('sourceAssessment'),
badgeColor: trust >= 70 ? '#22c55e' : trust >= 40 ? '#eab308' : trust >= 20 ? '#f97316' : '#ef4444',
badgeIcon: trust >= 70 ? ShieldCheck : trust >= 40 ? ShieldAlert : AlertOctagon,
};
}
if (single === 'ai_tampered' || single === 'ai-tampered') {
const score = item.risk_score ?? 0;
return {
badgeScore: score,
label: t('aiDetection'),
badgeColor: score >= 80 ? '#ef4444' : score >= 60 ? '#f97316' : score >= 40 ? '#eab308' : '#22c55e',
badgeIcon: score >= 60 ? AlertTriangle : Cpu,
};
}
if (single === 'techniques') {
const score = item.risk_score ?? 0;
return {
badgeScore: score,
label: t('manipulationTechniques'),
badgeColor: score >= 70 ? '#ef4444' : score >= 40 ? '#f97316' : score >= 20 ? '#eab308' : '#22c55e',
badgeIcon: score >= 70 ? AlertOctagon : score >= 40 ? AlertTriangle : ShieldCheck,
};
}
if (single === 'claims') {
const score = item.risk_score ?? 0;
return {
badgeScore: score,
label: t('claimsVerification'),
badgeColor: ACCENT.violet,
badgeIcon: CheckSquare,
};
}
// Pipeline (multi-component or unknown) — use risk_score + risk_category
const score = item.risk_score ?? 0;
return {
badgeScore: score,
label: item.risk_category
? enumLabel(item.risk_category)
: (item.risk_level ? enumLabel(item.risk_level) : t('analysis')),
badgeColor: score >= 70 ? '#ef4444'
: score >= 50 ? '#f97316'
: score >= 30 ? '#eab308'
: score > 0 ? '#22c55e'
: ACCENT.violet,
badgeIcon: score >= 70 ? AlertOctagon : score >= 30 ? AlertTriangle : score > 0 ? ShieldCheck : HelpCircle,
};
}
export default function HistoryCard({ item, onPress, onLongPress, progress }: Props) {
const { t, language } = useTranslation();
const isRo = language === 'ro';
const TypeIcon = TYPE_ICON[item.input_type || 'text'] || AlertCircle;
const statusDone = item.status === 'completed';
const isRunning = item.status === 'running' || item.status === 'pending' || item.status === 'processing';
const summary = summarizeSession(item, t, isRo);
const a11yLabel = [
item.input_text || item.input_url || t('mediaAnalysis'),
item.created_at ? formatRelativeTime(item.created_at) : null,
statusDone ? `${summary.label}, ${Math.round(summary.badgeScore)}` : item.status,
]
.filter(Boolean)
.join('. ');
return (
<TouchableOpacity
style={styles.card}
onPress={onPress}
onLongPress={onLongPress}
activeOpacity={0.75}
accessibilityRole="button"
accessibilityLabel={a11yLabel}
>
{/* Type icon */}
<View style={styles.typeIconBox}>
<TypeIcon size={18} color={COLORS.brand.primaryLight} strokeWidth={2} />
</View>
{/* Content */}
<View style={styles.content}>
<Text style={styles.inputText} numberOfLines={2}>
{item.input_text || item.input_url || t('mediaAnalysis')}
</Text>
<View style={styles.metaRow}>
{item.created_at && (
<Text style={styles.time}>{formatRelativeTime(item.created_at)}</Text>
)}
{isRunning && (
<View style={styles.statusBadge}>
<Text style={styles.statusText}>
{progress ? `${Math.round(progress.progress)}%` : item.status}
</Text>
</View>
)}
{!statusDone && !isRunning && item.status && (
<View style={styles.statusBadge}>
<Text style={styles.statusText}>{item.status}</Text>
</View>
)}
</View>
{isRunning && progress && progress.progress > 0 && (
<View style={styles.progressBarBg}>
<View style={[styles.progressBarFill, { width: `${Math.min(progress.progress, 100)}%` }]} />
</View>
)}
</View>
{/* Score / state */}
<View style={styles.scoreBox}>
{statusDone ? (
<>
<View style={[styles.scoreCircle, { borderColor: summary.badgeColor }]}>
<Text
style={[styles.scoreNum, { color: summary.badgeColor }]}
maxFontSizeMultiplier={1.5}
>
{Math.round(summary.badgeScore)}
</Text>
</View>
<View style={[styles.catBadge, { backgroundColor: getBadgeBgColor(summary.badgeColor, 0.16) }]}>
<summary.badgeIcon size={11} color={summary.badgeColor} strokeWidth={2.4} />
<Text
style={[styles.catText, { color: summary.badgeColor }]}
numberOfLines={1}
maxFontSizeMultiplier={1.5}
>
{summary.label}
</Text>
</View>
</>
) : (
<ActivityIndicator size="small" color={COLORS.brand.primaryLight} />
)}
</View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
card: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.025)',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.06)',
borderRadius: RADIUS.xl,
padding: 14,
marginBottom: SPACING.sm + 2,
},
typeIconBox: {
width: 38,
height: 38,
borderRadius: RADIUS.lg,
backgroundColor: 'rgba(167,139,250,0.10)',
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
content: {
flex: 1,
marginRight: SPACING.sm,
},
inputText: {
color: COLORS.text.primary,
fontSize: FONT_SIZES.sm,
lineHeight: 19,
marginBottom: 4,
},
metaRow: {
flexDirection: 'row',
alignItems: 'center',
},
time: {
color: COLORS.text.muted,
fontSize: FONT_SIZES.xs,
},
statusBadge: {
marginLeft: SPACING.sm,
paddingHorizontal: SPACING.sm,
paddingVertical: 1,
borderRadius: RADIUS.full,
backgroundColor: 'rgba(255,255,255,0.06)',
},
statusText: {
color: COLORS.status.yellow,
fontSize: FONT_SIZES.xs - 1,
textTransform: 'capitalize',
},
progressBarBg: {
width: '100%',
height: 3,
borderRadius: 1.5,
backgroundColor: 'rgba(255,255,255,0.08)',
overflow: 'hidden',
marginTop: SPACING.xs,
},
progressBarFill: {
height: '100%',
borderRadius: 1.5,
backgroundColor: COLORS.brand.primary,
},
scoreBox: {
alignItems: 'center',
minWidth: 64,
gap: 4,
},
scoreCircle: {
width: 44,
height: 44,
borderRadius: 22,
borderWidth: 2,
justifyContent: 'center',
alignItems: 'center',
},
scoreNum: {
fontSize: 16,
fontWeight: FONT_WEIGHTS.bold,
letterSpacing: -0.4,
},
catBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 3,
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 6,
maxWidth: 90,
},
catText: {
fontSize: 9.5,
fontWeight: FONT_WEIGHTS.semibold,
letterSpacing: 0.4,
textTransform: 'uppercase',
},
});

View file

@ -0,0 +1,286 @@
import React, { useRef, useState, useEffect, useCallback } from 'react';
import {
View,
TouchableOpacity,
Text,
StyleSheet,
Animated,
Platform,
LayoutChangeEvent,
} from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import {
COLORS,
SPACING,
RADIUS,
FONT_SIZES,
FONT_WEIGHTS,
} from '../theme/colors';
interface Tab {
key: string;
label: string;
}
interface TabSelectorProps {
tabs: Tab[];
activeTab: string;
onTabChange: (key: string) => void;
}
const ICON_MAP: Record<string, string> = {
text: 'T',
image: 'I',
audio: 'A',
video: 'V',
url: 'U',
};
const TRACK_PADDING = 3;
const SPRING_CONFIG = {
tension: 180,
friction: 22,
useNativeDriver: false,
};
export default function TabSelector({
tabs,
activeTab,
onTabChange,
}: TabSelectorProps) {
const tabLayouts = useRef<{ x: number; width: number }[]>([]);
const measuredCount = useRef(0);
const [layoutReady, setLayoutReady] = useState(false);
const isFirstRender = useRef(true);
// Animated values for the pill
const pillX = useRef(new Animated.Value(0)).current;
const pillWidth = useRef(new Animated.Value(0)).current;
// Per-tab animated values
const fadeAnims = useRef<Animated.Value[]>([]);
const scaleAnims = useRef<Animated.Value[]>([]);
// Initialize per-tab animated values when tabs change
if (fadeAnims.current.length !== tabs.length) {
fadeAnims.current = tabs.map(
(tab, i) => new Animated.Value(tab.key === activeTab ? 1 : 0),
);
scaleAnims.current = tabs.map(
(tab, i) => new Animated.Value(tab.key === activeTab ? 1.05 : 1),
);
}
const activeIndex = tabs.findIndex((t) => t.key === activeTab);
const handleLayout = useCallback(
(index: number, event: LayoutChangeEvent) => {
const { x, width } = event.nativeEvent.layout;
const isNew = !tabLayouts.current[index];
tabLayouts.current[index] = { x, width };
if (isNew) {
measuredCount.current += 1;
}
if (measuredCount.current >= tabs.length && !layoutReady) {
setLayoutReady(true);
}
},
[tabs.length, layoutReady],
);
// Set initial pill position once layout is ready
useEffect(() => {
if (!layoutReady) return;
const layout = tabLayouts.current[activeIndex];
if (!layout) return;
if (isFirstRender.current) {
isFirstRender.current = false;
pillX.setValue(layout.x);
pillWidth.setValue(layout.width);
return;
}
// Animate pill to new position
const animations: Animated.CompositeAnimation[] = [
Animated.spring(pillX, { ...SPRING_CONFIG, toValue: layout.x }),
Animated.spring(pillWidth, { ...SPRING_CONFIG, toValue: layout.width }),
];
// Animate tab fade and scale
tabs.forEach((tab, i) => {
const isActive = i === activeIndex;
animations.push(
Animated.timing(fadeAnims.current[i], {
toValue: isActive ? 1 : 0,
duration: 200,
useNativeDriver: false,
}),
Animated.spring(scaleAnims.current[i], {
...SPRING_CONFIG,
toValue: isActive ? 1.05 : 1,
}),
);
});
Animated.parallel(animations).start();
}, [activeIndex, layoutReady]);
return (
<View style={styles.container}>
<View style={styles.track}>
{/* Animated gradient pill */}
{layoutReady && (
<Animated.View
style={[
styles.pillWrapper,
{
transform: [{ translateX: pillX }],
width: pillWidth,
},
]}
>
<LinearGradient
colors={['#009198', '#1fb6bd']}
start={{ x: 0, y: 1 }}
end={{ x: 1, y: 0 }}
style={styles.pill}
/>
</Animated.View>
)}
{/* Tab items */}
{tabs.map((tab, index) => {
const fadeAnim = fadeAnims.current[index];
const scaleAnim = scaleAnims.current[index];
const icon = ICON_MAP[tab.key] || tab.label.charAt(0).toUpperCase();
// Interpolate colors based on fade value
const textColor = fadeAnim
? fadeAnim.interpolate({
inputRange: [0, 1],
outputRange: [COLORS.text.muted, '#FFFFFF'],
})
: COLORS.text.muted;
const badgeBg = fadeAnim
? fadeAnim.interpolate({
inputRange: [0, 1],
outputRange: ['rgba(255,255,255,0.06)', 'rgba(255,255,255,0.20)'],
})
: 'rgba(255,255,255,0.06)';
const badgeTextColor = fadeAnim
? fadeAnim.interpolate({
inputRange: [0, 1],
outputRange: [COLORS.text.darkMuted, '#FFFFFF'],
})
: COLORS.text.darkMuted;
const isActive = tab.key === activeTab;
return (
<TouchableOpacity
key={tab.key}
activeOpacity={0.7}
onPress={() => onTabChange(tab.key)}
style={styles.tabTouchable}
onLayout={(e) => handleLayout(index, e)}
accessibilityRole="tab"
accessibilityLabel={tab.label}
accessibilityState={{ selected: isActive }}
>
<Animated.View
style={[
styles.tabContent,
scaleAnim ? { transform: [{ scale: scaleAnim }] } : undefined,
]}
>
<Animated.View
style={[styles.iconBadge, { backgroundColor: badgeBg }]}
>
<Animated.Text
style={[styles.iconText, { color: badgeTextColor }]}
maxFontSizeMultiplier={1.5}
>
{icon}
</Animated.Text>
</Animated.View>
<Animated.Text
style={[styles.tabLabel, { color: textColor }]}
maxFontSizeMultiplier={1.5}
>
{tab.label}
</Animated.Text>
</Animated.View>
</TouchableOpacity>
);
})}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
paddingVertical: SPACING.sm,
},
track: {
flexDirection: 'row',
backgroundColor: COLORS.glass.white5,
borderWidth: 1,
borderColor: COLORS.glass.purple10,
borderRadius: RADIUS.xl,
padding: TRACK_PADDING,
position: 'relative',
},
pillWrapper: {
position: 'absolute',
top: TRACK_PADDING,
bottom: TRACK_PADDING,
left: 0,
zIndex: 0,
borderRadius: RADIUS.xl - TRACK_PADDING,
...Platform.select({
ios: {
shadowColor: '#009198',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.45,
shadowRadius: 8,
},
android: {
elevation: 6,
},
}),
},
pill: {
flex: 1,
borderRadius: RADIUS.xl - TRACK_PADDING,
},
tabTouchable: {
flex: 1,
zIndex: 1,
},
tabContent: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: SPACING.sm,
gap: 4,
},
iconBadge: {
width: 22,
height: 22,
borderRadius: 6,
alignItems: 'center',
justifyContent: 'center',
},
iconText: {
fontSize: 11,
fontWeight: FONT_WEIGHTS.bold,
},
tabLabel: {
fontSize: FONT_SIZES.xs,
fontWeight: FONT_WEIGHTS.bold,
},
});

View file

@ -0,0 +1,219 @@
import React, { useState } from 'react';
import { Modal, View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import {
COLORS,
GRADIENTS,
SPACING,
RADIUS,
FONT_SIZES,
FONT_WEIGHTS,
LAYOUT,
SHADOWS,
} from '../theme/colors';
import { useTranslation } from '../i18n';
interface UploadConsentModalProps {
visible: boolean;
onAccept: () => void;
onCancel: () => void;
}
/**
* Explicit consent dialog shown before the first media file upload.
* The user must tick the checkbox before the confirm button is enabled.
*/
export default function UploadConsentModal({
visible,
onAccept,
onCancel,
}: UploadConsentModalProps) {
const { t } = useTranslation();
const [checked, setChecked] = useState(false);
const handleAccept = () => {
if (!checked) return;
setChecked(false);
onAccept();
};
const handleCancel = () => {
setChecked(false);
onCancel();
};
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={handleCancel}
>
<View style={styles.overlay}>
<View style={styles.dialog}>
<Text style={styles.title} maxFontSizeMultiplier={1.5}>
{t('uploadConsentTitle')}
</Text>
<Text style={styles.body}>{t('uploadConsentBody')}</Text>
<TouchableOpacity
style={styles.checkboxRow}
activeOpacity={0.7}
onPress={() => setChecked((c) => !c)}
accessibilityRole="checkbox"
accessibilityState={{ checked }}
accessibilityLabel={t('uploadConsentCheckbox')}
>
<View style={[styles.checkbox, checked && styles.checkboxChecked]}>
{checked && <Text style={styles.checkmark}></Text>}
</View>
<Text style={styles.checkboxLabel}>{t('uploadConsentCheckbox')}</Text>
</TouchableOpacity>
<View style={styles.buttonsRow}>
<TouchableOpacity
style={styles.cancelButton}
activeOpacity={0.8}
onPress={handleCancel}
accessibilityRole="button"
accessibilityLabel={t('cancel')}
>
<Text style={styles.cancelText} maxFontSizeMultiplier={1.5}>
{t('cancel')}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={{ flex: 1 }}
activeOpacity={0.8}
onPress={handleAccept}
disabled={!checked}
accessibilityRole="button"
accessibilityLabel={t('uploadConsentAccept')}
accessibilityState={{ disabled: !checked }}
>
<LinearGradient
colors={checked
? [...GRADIENTS.button.colors] as [string, string]
: [COLORS.glass.white10, COLORS.glass.white10]}
start={GRADIENTS.button.start}
end={GRADIENTS.button.end}
style={[styles.acceptButton, !checked && styles.acceptDisabled]}
>
<Text
style={[styles.acceptText, !checked && styles.acceptTextDisabled]}
maxFontSizeMultiplier={1.5}
>
{t('uploadConsentAccept')}
</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: COLORS.glass.black50,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: SPACING.lg,
},
dialog: {
width: '100%',
maxWidth: 420,
backgroundColor: COLORS.bg.dialog,
borderWidth: 1,
borderColor: COLORS.border.accent,
borderRadius: RADIUS.xxl,
padding: LAYOUT.CARD_PADDING,
...SHADOWS.dialog,
},
title: {
color: COLORS.text.primary,
fontSize: FONT_SIZES.lg,
fontWeight: FONT_WEIGHTS.bold,
marginBottom: SPACING.sm,
},
body: {
color: COLORS.text.secondary,
fontSize: FONT_SIZES.sm,
lineHeight: 20,
marginBottom: SPACING.md,
},
checkboxRow: {
flexDirection: 'row',
alignItems: 'flex-start',
backgroundColor: COLORS.glass.white5,
borderRadius: RADIUS.lg,
padding: SPACING.md,
marginBottom: SPACING.lg,
},
checkbox: {
width: 22,
height: 22,
borderRadius: 6,
borderWidth: 2,
borderColor: COLORS.border.secondary,
backgroundColor: COLORS.glass.white10,
justifyContent: 'center',
alignItems: 'center',
marginRight: SPACING.sm + 4,
marginTop: 1,
},
checkboxChecked: {
borderColor: COLORS.brand.primary,
backgroundColor: COLORS.brand.primary,
},
checkmark: {
color: COLORS.text.primary,
fontSize: 13,
fontWeight: FONT_WEIGHTS.bold,
},
checkboxLabel: {
flex: 1,
color: COLORS.text.secondary,
fontSize: FONT_SIZES.xs,
lineHeight: 18,
},
buttonsRow: {
flexDirection: 'row',
gap: SPACING.md,
},
cancelButton: {
flex: 1,
backgroundColor: COLORS.glass.white5,
borderWidth: 1,
borderColor: COLORS.border.secondary,
borderRadius: RADIUS.xl,
paddingVertical: SPACING.sm + 4,
alignItems: 'center',
justifyContent: 'center',
},
cancelText: {
color: COLORS.text.secondary,
fontSize: FONT_SIZES.sm,
fontWeight: FONT_WEIGHTS.bold,
},
acceptButton: {
borderRadius: RADIUS.xl,
paddingVertical: SPACING.sm + 4,
alignItems: 'center',
justifyContent: 'center',
},
acceptDisabled: {
opacity: 0.5,
},
acceptText: {
color: COLORS.text.primary,
fontSize: FONT_SIZES.sm,
fontWeight: FONT_WEIGHTS.bold,
},
acceptTextDisabled: {
color: COLORS.text.muted,
},
});

View file

@ -0,0 +1,398 @@
import React, { useState } from 'react';
import {
View,
Text,
TouchableOpacity,
StyleSheet,
Animated,
LayoutAnimation,
Platform,
UIManager,
} from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import {
COLORS,
GRADIENTS,
SPACING,
RADIUS,
FONT_SIZES,
FONT_WEIGHTS,
LAYOUT,
getBadgeBgColor,
} from '../theme/colors';
import { UserProfile } from '../types/auth';
import { useTranslation } from '../i18n';
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
UIManager.setLayoutAnimationEnabledExperimental(true);
}
interface UserProfileWidgetProps {
profile: UserProfile | null;
usageStats: any;
creditsData?: { credits_remained: number; credits_spent: number } | null;
}
export default function UserProfileWidget({ profile, usageStats, creditsData }: UserProfileWidgetProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const navigation = useNavigation<NativeStackNavigationProp<any>>();
const initials = profile
? `${profile.firstName?.[0] || ''}${profile.lastName?.[0] || ''}`.toUpperCase()
: '?';
const plan = usageStats?.plan as any;
const sub = usageStats?.subscription as any;
const planName = plan?.name || 'Free';
// Credits from auth/credits endpoint (authoritative source)
const credits = creditsData?.credits_remained ?? profile?.creditsRemained ?? 0;
const creditSpent = creditsData?.credits_spent ?? profile?.creditsSpent ?? 0;
const creditTotal = credits + creditSpent;
const toggleExpand = () => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
setExpanded(!expanded);
};
// Credit color based on remaining
const creditColor =
credits < 5 ? COLORS.status.red :
credits <= 10 ? COLORS.status.yellow :
COLORS.status.green;
return (
<View style={styles.container}>
{/* Collapsed Bar */}
<TouchableOpacity activeOpacity={0.8} onPress={toggleExpand} style={styles.triggerBar}>
<LinearGradient
colors={[...GRADIENTS.avatar.colors] as [string, string, ...string[]]}
start={GRADIENTS.avatar.start}
end={GRADIENTS.avatar.end}
style={styles.avatarSmall}
>
<Text style={styles.avatarSmallText}>{initials}</Text>
</LinearGradient>
<Text style={styles.triggerName} numberOfLines={1}>
{profile ? profile.firstName : '...'}
</Text>
<Text style={[styles.triggerCredits, { color: creditColor }]}>{t('creditsLabel', { count: credits })}</Text>
<View style={styles.planBadge}>
<Text style={styles.planBadgeText}>{planName.toUpperCase()}</Text>
</View>
<Text style={styles.arrow}>{expanded ? '▴' : '▾'}</Text>
</TouchableOpacity>
{/* Expanded Dropdown */}
{expanded && (
<View style={styles.dropdown}>
{/* Section 1: User Info */}
<View style={styles.userInfoSection}>
<LinearGradient
colors={[...GRADIENTS.avatar.colors] as [string, string, ...string[]]}
start={GRADIENTS.avatar.start}
end={GRADIENTS.avatar.end}
style={styles.avatarLarge}
>
<Text style={styles.avatarLargeText}>{initials}</Text>
</LinearGradient>
<View style={styles.userInfoText}>
<Text style={styles.userName}>
{profile ? `${profile.firstName} ${profile.lastName}` : t('loading')}
</Text>
<Text style={styles.userEmail}>{profile?.email || ''}</Text>
<View style={styles.planRow}>
<View style={styles.planBadgeLarge}>
<Text style={styles.planBadgeLargeText}>{planName}</Text>
</View>
{usageStats && (
<Text style={styles.planPrice}>
${plan?.priceAmount ?? plan?.price_amount ?? 0}{t('perMonth')}
</Text>
)}
<View style={[
styles.statusDot,
{ backgroundColor: (sub?.isActive ?? sub?.is_active) ? COLORS.status.green : COLORS.status.red }
]} />
<Text style={styles.statusText}>
{(sub?.isActive ?? sub?.is_active) ? t('active') : t('inactive')}
</Text>
</View>
</View>
</View>
<View style={styles.divider} />
{/* Section 2: Credits + Limits */}
<View style={styles.columnsRow}>
{/* Credits Column */}
<View style={styles.column}>
<Text style={styles.columnTitle}>{t('credits')}</Text>
<View style={styles.statRow}>
<Text style={styles.statLabel}>{t('remaining')}</Text>
<Text style={[styles.statValue, { color: creditColor }]}>{credits}</Text>
</View>
</View>
{/* Limits Column */}
<View style={styles.column}>
<Text style={styles.columnTitle}>{t('planLimits')}</Text>
<View style={styles.statRow}>
<Text style={styles.statLabel}>{t('creditsCycle')}</Text>
<Text style={styles.statValue}>{creditTotal || '—'}</Text>
</View>
<View style={styles.statRow}>
<Text style={styles.statLabel}>{t('maxImages')}</Text>
<Text style={styles.statValue}>{plan?.maxImages ?? plan?.max_images ?? '—'}</Text>
</View>
<View style={styles.statRow}>
<Text style={styles.statLabel}>{t('videoMinutes')}</Text>
<Text style={styles.statValue}>{plan?.maxVideoMinutes ?? plan?.max_video_minutes ?? '—'}</Text>
</View>
<View style={styles.statRow}>
<Text style={styles.statLabel}>{t('storage')}</Text>
<Text style={styles.statValue}>{plan?.storageLimitGb ?? plan?.storage_limit_gb ?? '—'} GB</Text>
</View>
</View>
</View>
<View style={styles.divider} />
{/* Section 3: Credit Costs + Settings */}
<View style={styles.footerRow}>
<View style={styles.costsRow}>
<Text style={styles.costItem}>Text: {usageStats?.costs?.text ?? 1}cr</Text>
<Text style={styles.costDivider}>|</Text>
<Text style={styles.costItem}>URL: {usageStats?.costs?.url ?? 1}cr</Text>
<Text style={styles.costDivider}>|</Text>
<Text style={styles.costItem}>Img: {usageStats?.costs?.image ?? 2}cr</Text>
<Text style={styles.costDivider}>|</Text>
<Text style={styles.costItem}>Audio: {usageStats?.costs?.audio ?? 3}cr</Text>
<Text style={styles.costDivider}>|</Text>
<Text style={styles.costItem}>Video: {usageStats?.costs?.video ?? 5}cr</Text>
</View>
<TouchableOpacity
onPress={() => navigation.navigate('Settings')}
style={styles.settingsButton}
>
<Text style={styles.settingsText}>{t('settings')}</Text>
</TouchableOpacity>
</View>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: SPACING.lg,
},
// Trigger Bar
triggerBar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: COLORS.bg.card,
borderWidth: 1,
borderColor: COLORS.border.accent,
borderRadius: RADIUS.xxl,
paddingVertical: SPACING.sm + 4,
paddingHorizontal: SPACING.md,
minHeight: 52,
},
avatarSmall: {
width: 32,
height: 32,
borderRadius: RADIUS.full,
justifyContent: 'center',
alignItems: 'center',
marginRight: SPACING.sm,
},
avatarSmallText: {
color: COLORS.text.primary,
fontSize: FONT_SIZES.xs,
fontWeight: FONT_WEIGHTS.bold,
},
triggerName: {
color: COLORS.text.primary,
fontSize: FONT_SIZES.sm,
fontWeight: FONT_WEIGHTS.bold,
marginRight: SPACING.sm,
flexShrink: 1,
},
triggerCredits: {
color: COLORS.brand.accentCyan,
fontSize: FONT_SIZES.xs,
fontWeight: FONT_WEIGHTS.bold,
marginRight: SPACING.sm,
},
planBadge: {
backgroundColor: COLORS.glass.cyan10,
borderWidth: 1,
borderColor: COLORS.glass.cyan20,
borderRadius: RADIUS.lg,
paddingHorizontal: SPACING.sm,
paddingVertical: 2,
marginRight: SPACING.sm,
},
planBadgeText: {
color: COLORS.brand.accentCyan,
fontSize: 10,
fontWeight: FONT_WEIGHTS.bold,
},
arrow: {
color: COLORS.text.hint,
fontSize: FONT_SIZES.sm,
marginLeft: 'auto',
},
// Dropdown
dropdown: {
backgroundColor: COLORS.bg.card,
borderWidth: 1,
borderColor: COLORS.border.accent,
borderRadius: RADIUS.xxl,
padding: LAYOUT.CARD_PADDING,
marginTop: SPACING.sm,
},
// Section 1
userInfoSection: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
avatarLarge: {
width: LAYOUT.AVATAR_SIZE_LARGE,
height: LAYOUT.AVATAR_SIZE_LARGE,
borderRadius: RADIUS.full,
justifyContent: 'center',
alignItems: 'center',
marginRight: SPACING.md,
},
avatarLargeText: {
color: COLORS.text.primary,
fontSize: FONT_SIZES.xl,
fontWeight: FONT_WEIGHTS.bold,
},
userInfoText: {
flex: 1,
},
userName: {
color: COLORS.text.primary,
fontSize: FONT_SIZES.base,
fontWeight: FONT_WEIGHTS.bold,
},
userEmail: {
color: COLORS.text.secondary,
fontSize: FONT_SIZES.sm,
marginTop: 2,
},
planRow: {
flexDirection: 'row',
alignItems: 'center',
marginTop: SPACING.sm,
flexWrap: 'wrap',
gap: SPACING.sm,
},
planBadgeLarge: {
backgroundColor: COLORS.glass.cyan10,
borderWidth: 1,
borderColor: COLORS.glass.cyan20,
borderRadius: RADIUS.lg,
paddingHorizontal: SPACING.sm + 4,
paddingVertical: SPACING.xs,
},
planBadgeLargeText: {
color: COLORS.brand.accentCyan,
fontSize: FONT_SIZES.xs,
fontWeight: FONT_WEIGHTS.bold,
},
planPrice: {
color: COLORS.text.secondary,
fontSize: FONT_SIZES.xs,
},
statusDot: {
width: 8,
height: 8,
borderRadius: RADIUS.full,
},
statusText: {
color: COLORS.text.secondary,
fontSize: FONT_SIZES.xs,
},
divider: {
height: 1,
backgroundColor: COLORS.border.accent,
marginVertical: SPACING.md,
},
// Section 2
columnsRow: {
flexDirection: 'row',
gap: SPACING.md,
},
column: {
flex: 1,
},
columnTitle: {
color: COLORS.text.primary,
fontSize: FONT_SIZES.sm,
fontWeight: FONT_WEIGHTS.bold,
marginBottom: SPACING.sm,
},
statRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: SPACING.xs + 2,
},
statLabel: {
color: COLORS.text.hint,
fontSize: FONT_SIZES.xs,
},
statValue: {
color: COLORS.text.primary,
fontSize: FONT_SIZES.xs,
fontWeight: FONT_WEIGHTS.bold,
},
// Section 3
footerRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
minHeight: 40,
},
costsRow: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
flex: 1,
gap: 4,
},
costItem: {
color: COLORS.text.hint,
fontSize: 10,
},
costDivider: {
color: COLORS.text.muted,
fontSize: 10,
},
settingsButton: {
backgroundColor: COLORS.glass.purple10,
borderRadius: RADIUS.lg,
paddingHorizontal: SPACING.md,
paddingVertical: SPACING.sm,
marginLeft: SPACING.sm,
},
settingsText: {
color: COLORS.brand.primary,
fontSize: FONT_SIZES.xs,
fontWeight: FONT_WEIGHTS.bold,
},
});

View file

@ -0,0 +1,93 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import type { LucideIcon } from 'lucide-react-native';
import { COLORS, SPACING, RADIUS, FONT_WEIGHTS } from '../../theme/colors';
interface Props {
accent: string;
icon: LucideIcon;
/** Tiny uppercase tag (e.g. "DO NOT SHARE", "READ CRITICALLY"). */
eyebrow: string;
/** Bold one-liner action prompt. */
headline: string;
/** Optional descriptive paragraph. */
body?: string;
}
function withAlpha(hex: string, alpha: number) {
const cleaned = hex.replace('#', '');
const r = parseInt(cleaned.slice(0, 2), 16);
const g = parseInt(cleaned.slice(2, 4), 16);
const b = parseInt(cleaned.slice(4, 6), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
export default function ActionCallout({ accent, icon: Icon, eyebrow, headline, body }: Props) {
return (
<View style={[styles.card, { borderColor: withAlpha(accent, 0.45) }]}>
<LinearGradient
colors={[withAlpha(accent, 0.28), withAlpha(accent, 0.10)] as [string, string]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={StyleSheet.absoluteFill}
/>
<View style={styles.row}>
<View style={[styles.iconBox, { backgroundColor: withAlpha(accent, 0.32) }]}>
<Icon size={24} color={accent} strokeWidth={2} />
</View>
<View style={styles.body}>
<Text style={[styles.eyebrow, { color: accent }]}>{eyebrow}</Text>
<Text style={styles.headline}>{headline}</Text>
{body ? <Text style={styles.text}>{body}</Text> : null}
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
card: {
borderWidth: 1,
borderRadius: RADIUS.xxl,
padding: 20,
overflow: 'hidden',
marginBottom: SPACING.md,
},
row: {
flexDirection: 'row',
gap: 16,
alignItems: 'flex-start',
},
iconBox: {
width: 42,
height: 42,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
flexShrink: 0,
},
body: {
flex: 1,
gap: 4,
},
eyebrow: {
fontSize: 11,
fontWeight: FONT_WEIGHTS.bold,
letterSpacing: 1.3,
textTransform: 'uppercase',
},
headline: {
fontSize: 18,
fontWeight: FONT_WEIGHTS.semibold,
color: '#fff',
letterSpacing: -0.4,
lineHeight: 24,
},
text: {
marginTop: 4,
fontSize: 14,
lineHeight: 21,
color: 'rgba(255,255,255,0.75)',
},
});

View file

@ -0,0 +1,62 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { ACCENT, type FindingAccent, FONT_WEIGHTS } from '../../theme/colors';
interface ChipProps {
variant?: FindingAccent | 'default';
children: React.ReactNode;
/** Optional leading icon node (e.g. a Lucide <Search size={12} />). */
leading?: React.ReactNode;
}
const VARIANT_FG: Record<string, string> = {
...ACCENT,
default: ACCENT.violet,
};
function withAlpha(hex: string, alpha: number) {
const cleaned = hex.replace('#', '');
const r = parseInt(cleaned.slice(0, 2), 16);
const g = parseInt(cleaned.slice(2, 4), 16);
const b = parseInt(cleaned.slice(4, 6), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
export function Chip({ variant = 'default', children, leading }: ChipProps) {
const fg = VARIANT_FG[variant];
return (
<View style={[styles.chip, { backgroundColor: withAlpha(fg, 0.14) }]}>
{leading ? <View style={{ marginRight: 4 }}>{leading}</View> : null}
<Text style={[styles.text, { color: fg }]}>{children}</Text>
</View>
);
}
interface RowProps {
children: React.ReactNode;
style?: any;
}
export function ChipsRow({ children, style }: RowProps) {
return <View style={[styles.row, style]}>{children}</View>;
}
const styles = StyleSheet.create({
chip: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 5,
paddingHorizontal: 12,
borderRadius: 999,
},
text: {
fontSize: 12,
fontWeight: FONT_WEIGHTS.medium,
letterSpacing: -0.05,
},
row: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 6,
},
});

View file

@ -0,0 +1,59 @@
import React from 'react';
import { TouchableOpacity, View, Text, StyleSheet } from 'react-native';
import { ChevronDown } from 'lucide-react-native';
import { COLORS, SPACING, RADIUS, FONT_WEIGHTS } from '../../theme/colors';
interface Props {
onPress: () => void;
expanded: boolean;
label: string;
/** Optional leading icon node (defaults to none). */
leading?: React.ReactNode;
}
export default function CollapseButton({ onPress, expanded, label, leading }: Props) {
return (
<TouchableOpacity onPress={onPress} activeOpacity={0.7} style={styles.btn}>
<View style={styles.left}>
{leading}
<Text style={styles.label}>{label}</Text>
</View>
<View style={[styles.chevron, expanded && styles.chevronExpanded]}>
<ChevronDown size={14} color="rgba(255,255,255,0.6)" strokeWidth={2.5} />
</View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 12,
paddingHorizontal: 18,
backgroundColor: 'rgba(255,255,255,0.022)',
borderRadius: RADIUS.xl,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.05)',
marginBottom: SPACING.sm,
},
left: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
flex: 1,
},
label: {
fontSize: 13,
fontWeight: FONT_WEIGHTS.medium,
color: 'rgba(255,255,255,0.75)',
letterSpacing: -0.05,
},
chevron: {
transform: [{ rotate: '0deg' }],
},
chevronExpanded: {
transform: [{ rotate: '180deg' }],
},
});

View file

@ -0,0 +1,123 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import type { LucideIcon } from 'lucide-react-native';
import { COLORS, SPACING, RADIUS, EDITORIAL, ACCENT, type FindingAccent } from '../../theme/colors';
interface Props {
icon: LucideIcon;
accent?: FindingAccent;
/** When true, render a slightly larger emphasized variant. */
dominant?: boolean;
eyebrow?: React.ReactNode;
headline: React.ReactNode;
/** Optional pull-quote (Merriweather italic with left rule). */
quote?: React.ReactNode;
/** Optional source row (icons + links + separators). */
source?: React.ReactNode;
}
function withAlpha(hex: string, alpha: number) {
const cleaned = hex.replace('#', '');
const r = parseInt(cleaned.slice(0, 2), 16);
const g = parseInt(cleaned.slice(2, 4), 16);
const b = parseInt(cleaned.slice(4, 6), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
export default function FindingCard({
icon: Icon, accent = 'critical', dominant, eyebrow, headline, quote, source,
}: Props) {
const hex = ACCENT[accent];
const iconSize = dominant ? 20 : 18;
const boxSize = dominant ? 38 : 34;
const boxRadius = dominant ? 11 : 10;
const borderColor = dominant
? withAlpha(hex, 0.22)
: 'rgba(255,255,255,0.08)';
return (
<View style={[styles.card, dominant && styles.dominant, { borderColor }]}>
<View style={[
styles.iconBox,
{
width: boxSize,
height: boxSize,
borderRadius: boxRadius,
backgroundColor: withAlpha(hex, 0.16),
},
]}>
<Icon size={iconSize} color={hex} strokeWidth={2} />
</View>
<View style={styles.body}>
{eyebrow ? (
<Text style={styles.eyebrow}>{eyebrow}</Text>
) : null}
<Text style={dominant ? styles.headlineDominant : styles.headline}>
{headline}
</Text>
{quote ? (
<View style={styles.quoteWrap}>
<View style={styles.quoteRule} />
<Text style={styles.quote}>{quote}</Text>
</View>
) : null}
{source ? <View style={styles.sourceRow}>{source}</View> : null}
</View>
</View>
);
}
const styles = StyleSheet.create({
card: {
flexDirection: 'row',
gap: 14,
paddingVertical: 18,
paddingHorizontal: 18,
borderWidth: 1,
borderRadius: RADIUS.xxl,
backgroundColor: 'rgba(255,255,255,0.025)',
marginBottom: SPACING.sm + 4,
},
dominant: {
paddingVertical: 20,
paddingHorizontal: 20,
},
iconBox: {
justifyContent: 'center',
alignItems: 'center',
flexShrink: 0,
},
body: {
flex: 1,
minWidth: 0,
gap: 8,
},
eyebrow: {
fontSize: 10.5,
letterSpacing: 1.3,
fontWeight: '600',
color: 'rgba(255,255,255,0.45)',
textTransform: 'uppercase',
},
headline: EDITORIAL.findingHeadline,
headlineDominant: EDITORIAL.findingHeadlineDominant,
quoteWrap: {
flexDirection: 'row',
gap: 12,
},
quoteRule: {
width: 2,
backgroundColor: 'rgba(255,255,255,0.14)',
borderRadius: 1,
},
quote: {
...EDITORIAL.quote,
flex: 1,
},
sourceRow: {
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'center',
gap: 6,
},
});

View file

@ -0,0 +1,246 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import Svg, { Circle } from 'react-native-svg';
import Animated, {
useSharedValue,
useAnimatedProps,
withDelay,
withTiming,
Easing,
} from 'react-native-reanimated';
import { LinearGradient } from 'expo-linear-gradient';
import type { LucideIcon } from 'lucide-react-native';
import { COLORS, SPACING, RADIUS, FONT_SIZES, FONT_WEIGHTS, EDITORIAL } from '../../theme/colors';
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
const GAUGE_SIZE = 132;
const STROKE_WIDTH = 7;
const RADIUS_PX = (GAUGE_SIZE - STROKE_WIDTH) / 2;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS_PX;
interface Props {
/** Hex color used for accent on icon-box, gauge stroke and gradient bg. */
accent: string;
/** Score 0100 displayed inside the gauge. */
score: number;
/** Optional secondary line for gauge center, e.g. "/ 100 RISK". */
scoreLabel?: string;
/** Small uppercase label above the category title. */
eyebrow: string;
/** Lucide icon rendered in the small accent square. */
icon: LucideIcon;
/** Big category name. */
category: string;
/** Inline descriptor next to category (already composed with separators). */
descriptor?: React.ReactNode;
/** Optional TL;DR paragraph (Merriweather italic). */
tldr?: React.ReactNode;
}
function withAlpha(hex: string, alpha: number): string {
const cleaned = hex.replace('#', '');
const r = parseInt(cleaned.slice(0, 2), 16);
const g = parseInt(cleaned.slice(2, 4), 16);
const b = parseInt(cleaned.slice(4, 6), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
export default function HeadlineCard({
accent, score, scoreLabel, eyebrow, icon: Icon, category, descriptor, tldr,
}: Props) {
const offset = useSharedValue(CIRCUMFERENCE);
const animatedScore = useSharedValue(0);
useEffect(() => {
const target = CIRCUMFERENCE * (1 - Math.min(1, Math.max(0, score / 100)));
offset.value = withDelay(200, withTiming(target, {
duration: 1100,
easing: Easing.out(Easing.cubic),
}));
animatedScore.value = withDelay(200, withTiming(score, {
duration: 1100,
easing: Easing.out(Easing.cubic),
}));
}, [score, offset, animatedScore]);
const animatedCircleProps = useAnimatedProps(() => ({
strokeDashoffset: offset.value,
}));
const [displayScore, setDisplayScore] = React.useState(0);
useEffect(() => {
const interval = setInterval(() => {
setDisplayScore(Math.round(animatedScore.value));
}, 16);
return () => clearInterval(interval);
}, [animatedScore]);
return (
<View style={[styles.card, { borderColor: withAlpha(accent, 0.18) }]}>
<LinearGradient
colors={[withAlpha(accent, 0.10), 'rgba(255,255,255,0.025)'] as [string, string]}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
style={StyleSheet.absoluteFill}
pointerEvents="none"
/>
<View style={styles.glow} pointerEvents="none">
<LinearGradient
colors={[withAlpha(accent, 0.18), 'transparent'] as [string, string]}
style={{ flex: 1, borderRadius: 999 }}
/>
</View>
{/* GAUGE — centered on top */}
<View style={styles.gaugeWrap}>
<Svg width={GAUGE_SIZE} height={GAUGE_SIZE} style={{ transform: [{ rotate: '-90deg' }] }}>
<Circle
cx={GAUGE_SIZE / 2}
cy={GAUGE_SIZE / 2}
r={RADIUS_PX}
stroke={accent}
strokeOpacity={0.12}
strokeWidth={STROKE_WIDTH}
fill="none"
/>
<AnimatedCircle
cx={GAUGE_SIZE / 2}
cy={GAUGE_SIZE / 2}
r={RADIUS_PX}
stroke={accent}
strokeWidth={STROKE_WIDTH}
strokeLinecap="round"
strokeDasharray={`${CIRCUMFERENCE},${CIRCUMFERENCE}`}
animatedProps={animatedCircleProps}
fill="none"
/>
</Svg>
<View style={styles.gaugeCenter}>
<Text style={styles.gaugeScore}>{displayScore}</Text>
{scoreLabel ? <Text style={styles.gaugeLabel}>{scoreLabel}</Text> : null}
</View>
</View>
{/* CONTENT — full-width below */}
<View style={styles.content}>
<View style={styles.eyebrowRow}>
<View style={styles.eyebrowRule} />
<Text style={styles.eyebrow} numberOfLines={1}>{eyebrow}</Text>
<View style={styles.eyebrowRule} />
</View>
<View style={styles.categoryRow}>
<View style={[styles.iconBox, { backgroundColor: withAlpha(accent, 0.18) }]}>
<Icon size={18} color={accent} strokeWidth={2.2} />
</View>
<Text style={styles.category} numberOfLines={2} adjustsFontSizeToFit>
{category}
</Text>
</View>
{descriptor ? <Text style={styles.descriptor}>{descriptor}</Text> : null}
{tldr ? <Text style={styles.tldr}>{tldr}</Text> : null}
</View>
</View>
);
}
const styles = StyleSheet.create({
card: {
borderWidth: 1,
borderRadius: RADIUS.xxl,
paddingVertical: 28,
paddingHorizontal: 24,
overflow: 'hidden',
backgroundColor: 'rgba(255,255,255,0.025)',
marginBottom: SPACING.md,
alignItems: 'center',
},
glow: {
position: 'absolute',
top: -120,
right: -120,
width: 280,
height: 280,
borderRadius: 999,
opacity: 0.65,
},
gaugeWrap: {
width: GAUGE_SIZE,
height: GAUGE_SIZE,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 22,
},
gaugeCenter: {
position: 'absolute',
top: 0, left: 0, right: 0, bottom: 0,
justifyContent: 'center',
alignItems: 'center',
},
gaugeScore: {
fontSize: 44,
fontWeight: FONT_WEIGHTS.semibold,
color: '#fff',
lineHeight: 46,
letterSpacing: -1.2,
},
gaugeLabel: {
marginTop: 4,
fontSize: 9.5,
letterSpacing: 1.2,
color: 'rgba(255,255,255,0.32)',
fontWeight: FONT_WEIGHTS.medium,
textTransform: 'uppercase',
},
content: {
width: '100%',
alignItems: 'center',
},
eyebrowRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
gap: 10,
width: '100%',
justifyContent: 'center',
},
eyebrowRule: {
width: 18,
height: 1,
backgroundColor: 'rgba(255,255,255,0.28)',
},
eyebrow: {
...EDITORIAL.eyebrow,
flexShrink: 1,
textAlign: 'center',
},
categoryRow: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
gap: 10,
marginBottom: 8,
justifyContent: 'center',
},
iconBox: {
width: 30,
height: 30,
borderRadius: 9,
justifyContent: 'center',
alignItems: 'center',
},
category: {
...EDITORIAL.category,
flexShrink: 1,
textAlign: 'center',
},
descriptor: {
...EDITORIAL.descriptor,
textAlign: 'center',
},
tldr: {
...EDITORIAL.tldr,
marginTop: 14,
textAlign: 'center',
},
});

View file

@ -0,0 +1,60 @@
import React from 'react';
import { View, Text, StyleSheet, ScrollView } from 'react-native';
import { useTranslation } from '../../i18n';
import { SPACING, RADIUS, FONT_WEIGHTS } from '../../theme/colors';
interface Props {
inputType: string;
text: string | null | undefined;
}
function toParagraphs(raw: string): string[] {
return raw
.split(/\n{2,}/)
.map(p => p.replace(/\s*\n\s*/g, ' ').trim())
.filter(Boolean);
}
export default function InputPreview({ inputType, text }: Props) {
const { t } = useTranslation();
if (!text) return null;
const paragraphs = toParagraphs(text);
return (
<View style={styles.block}>
<Text style={styles.label}>{t('inputLabel', { type: inputType })}</Text>
<ScrollView style={styles.scroll} nestedScrollEnabled>
{paragraphs.map((p, i) => (
<Text key={i} style={[styles.body, i > 0 && { marginTop: 10 }]}>{p}</Text>
))}
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
block: {
paddingVertical: 14,
paddingHorizontal: 18,
backgroundColor: 'rgba(255,255,255,0.018)',
borderRadius: RADIUS.xl,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.04)',
marginBottom: SPACING.md,
},
label: {
fontSize: 10.5,
letterSpacing: 1.6,
fontWeight: FONT_WEIGHTS.semibold,
color: 'rgba(255,255,255,0.45)',
textTransform: 'uppercase',
marginBottom: 6,
},
scroll: {
maxHeight: 220,
},
body: {
fontSize: 13,
lineHeight: 21,
color: 'rgba(255,255,255,0.7)',
},
});

View file

@ -0,0 +1,83 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import Animated, { useSharedValue, useAnimatedStyle, withDelay, withTiming, Easing } from 'react-native-reanimated';
import { FONT_WEIGHTS } from '../../theme/colors';
interface Props {
name: string;
score: number;
/** Hex color for the bar fill. */
color: string;
/** Stagger delay (ms) for the bar animation. */
delay?: number;
}
export default function ScoreLine({ name, score, color, delay = 0 }: Props) {
const width = useSharedValue(0);
useEffect(() => {
width.value = withDelay(delay, withTiming(Math.min(score, 100), {
duration: 900,
easing: Easing.out(Easing.cubic),
}));
}, [score, delay, width]);
const fillStyle = useAnimatedStyle(() => ({
width: `${width.value}%`,
}));
return (
<View style={styles.row}>
<Text style={styles.name}>{name}</Text>
<View style={styles.barBg}>
<Animated.View style={[styles.barFill, { backgroundColor: color }, fillStyle]} />
</View>
<Text style={styles.value}>{score}</Text>
</View>
);
}
interface BlockProps {
children: React.ReactNode;
}
export function ScoresBlock({ children }: BlockProps) {
return <View style={styles.block}>{children}</View>;
}
const styles = StyleSheet.create({
block: {
gap: 10,
},
row: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
name: {
fontSize: 13,
color: 'rgba(255,255,255,0.7)',
width: 100,
fontWeight: FONT_WEIGHTS.regular,
letterSpacing: -0.05,
},
barBg: {
flex: 1,
height: 4,
backgroundColor: 'rgba(255,255,255,0.06)',
borderRadius: 2,
overflow: 'hidden',
},
barFill: {
height: '100%',
borderRadius: 2,
},
value: {
fontSize: 13,
fontWeight: FONT_WEIGHTS.medium,
color: 'rgba(255,255,255,0.85)',
width: 40,
textAlign: 'right',
letterSpacing: -0.1,
},
});

View file

@ -0,0 +1,36 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { EDITORIAL, SPACING } from '../../theme/colors';
interface Props {
label: string;
count?: React.ReactNode;
}
export default function SectionDivider({ label, count }: Props) {
return (
<View style={styles.row}>
<Text style={styles.label}>{label}</Text>
<View style={styles.line} />
{count != null ? <Text style={styles.count}>{count}</Text> : null}
</View>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingVertical: 8,
paddingHorizontal: 4,
marginTop: SPACING.sm,
},
label: EDITORIAL.dividerLabel,
line: {
flex: 1,
height: 1,
backgroundColor: 'rgba(255,255,255,0.08)',
},
count: EDITORIAL.dividerCount,
});

View file

@ -0,0 +1,25 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { SPACING, RADIUS } from '../../theme/colors';
interface Props {
children: React.ReactNode;
}
/** A subtle container holding chips and/or scores, used right under the HeadlineCard. */
export default function StatBlock({ children }: Props) {
return <View style={styles.block}>{children}</View>;
}
const styles = StyleSheet.create({
block: {
paddingVertical: 18,
paddingHorizontal: 20,
backgroundColor: 'rgba(255,255,255,0.018)',
borderRadius: RADIUS.xl,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.04)',
gap: 14,
marginBottom: SPACING.sm + 4,
},
});

View file

@ -0,0 +1,9 @@
export { default as HeadlineCard } from './HeadlineCard';
export { default as FindingCard } from './FindingCard';
export { default as SectionDivider } from './SectionDivider';
export { default as ActionCallout } from './ActionCallout';
export { default as InputPreview } from './InputPreview';
export { default as CollapseButton } from './CollapseButton';
export { default as StatBlock } from './StatBlock';
export { default as ScoreLine, ScoresBlock } from './ScoreLine';
export { Chip, ChipsRow } from './Chip';

View file

@ -0,0 +1,114 @@
import React, { useState, useEffect, useRef } from 'react';
import { View, Text, StyleSheet, Animated } from 'react-native';
import { useTranslation } from '../../i18n';
import {
COLORS,
SPACING,
RADIUS,
FONT_SIZES,
FONT_WEIGHTS,
LAYOUT,
} from '../../theme/colors';
import type { TechniqueDefinition } from '../../types/analysis';
import { formatTechniqueName } from '../../utils/formatTechniqueName';
interface Props {
definitions: TechniqueDefinition[];
}
const CYCLE_INTERVAL = 7000;
export default function DidYouKnowCard({ definitions }: Props) {
const { t } = useTranslation();
const [index, setIndex] = useState(() =>
definitions.length > 0 ? Math.floor(Math.random() * definitions.length) : 0
);
const fadeAnim = useRef(new Animated.Value(1)).current;
useEffect(() => {
if (definitions.length <= 1) return;
const timer = setInterval(() => {
// Fade out
Animated.timing(fadeAnim, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}).start(() => {
// Pick a new random index (different from current)
setIndex((prev) => {
let next = Math.floor(Math.random() * definitions.length);
if (next === prev && definitions.length > 1) {
next = (next + 1) % definitions.length;
}
return next;
});
// Fade in
Animated.timing(fadeAnim, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}).start();
});
}, CYCLE_INTERVAL);
return () => clearInterval(timer);
}, [definitions.length, fadeAnim]);
if (definitions.length === 0) return null;
const def = definitions[index];
if (!def) return null;
return (
<View style={styles.card}>
<Text style={styles.title}>{t('didYouKnow')}</Text>
<Animated.View style={{ opacity: fadeAnim }}>
<Text style={styles.techniqueName}>
{formatTechniqueName(def.technique_name)}
</Text>
<Text style={styles.dimensionText}>
{def.dimension} {def.subdimension}
</Text>
<Text style={styles.description} numberOfLines={4}>
{def.description_en}
</Text>
</Animated.View>
</View>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: COLORS.glass.purple10,
borderWidth: 1,
borderColor: COLORS.glass.purple25,
borderRadius: RADIUS.xxl,
padding: LAYOUT.CARD_PADDING,
marginBottom: SPACING.md,
},
title: {
color: COLORS.brand.primaryLight,
fontSize: FONT_SIZES.sm,
fontWeight: FONT_WEIGHTS.bold,
marginBottom: SPACING.sm,
textTransform: 'uppercase',
letterSpacing: 1,
},
techniqueName: {
color: COLORS.text.primary,
fontSize: FONT_SIZES.base,
fontWeight: FONT_WEIGHTS.bold,
marginBottom: SPACING.xs,
},
dimensionText: {
color: COLORS.brand.primaryLight,
fontSize: FONT_SIZES.xs,
marginBottom: SPACING.sm,
},
description: {
color: COLORS.text.secondary,
fontSize: FONT_SIZES.sm,
lineHeight: 20,
},
});

View file

@ -0,0 +1,280 @@
import React, { useState } from 'react';
import { View } from 'react-native';
import {
Cpu, ShieldCheck, ShieldAlert, AlertOctagon, AlertTriangle, AlertCircle,
FileText, Image as ImageIcon, Sparkles, Plus,
} from 'lucide-react-native';
import type { LucideIcon } from 'lucide-react-native';
import {
HeadlineCard, FindingCard, SectionDivider, StatBlock, ChipsRow, Chip, CollapseButton,
} from '../editorial';
import type { FindingAccent } from '../../theme/colors';
import type { V3AiTamperedResult, V3AiIndicator } from '../../types/analysis';
import { localized, useTranslation, enumLabel } from '../../i18n';
const DEFAULT_VISIBLE_INDICATORS = 5;
const probAccent = (p: number) =>
p >= 80 ? '#ef4444' : p >= 60 ? '#f97316' : p >= 40 ? '#eab308' : '#22c55e';
const probIcon = (p: number): LucideIcon => {
if (p >= 80) return AlertOctagon;
if (p >= 60) return AlertTriangle;
if (p >= 40) return Cpu;
return ShieldCheck;
};
const indicatorAccent = (confidence: number): FindingAccent => {
if (confidence >= 80) return 'critical';
if (confidence >= 60) return 'warning';
if (confidence >= 40) return 'info';
return 'neutral';
};
const VERDICT_LABELS: Record<string, { ro: string; en: string }> = {
LIKELY_AI: { ro: 'Probabil AI', en: 'Likely AI' },
POSSIBLY_AI: { ro: 'Posibil AI', en: 'Possibly AI' },
UNLIKELY_AI: { ro: 'Improbabil AI', en: 'Unlikely AI' },
LIKELY_HUMAN: { ro: 'Probabil uman', en: 'Likely human' },
HUMAN: { ro: 'Uman', en: 'Human' },
MIXED: { ro: 'Mixt', en: 'Mixed' },
};
const CATEGORY_LABELS: Record<string, { ro: string; en: string }> = {
T1: { ro: 'Stil de scriere', en: 'Writing style' },
T2: { ro: 'Tipare conținut', en: 'Content patterns' },
T3: { ro: 'Analiză structurală', en: 'Structural analysis' },
T4: { ro: 'Markeri statistici', en: 'Statistical markers' },
T5: { ro: 'Semnale explicite', en: 'Explicit signals' },
};
export interface AiDisplayResult {
verdict: string;
ai_probability: number;
disclosure_detected?: boolean;
disclosure_explicit?: boolean;
disclosure_text?: string | null;
indicators_found?: string[];
indicators_detected?: V3AiIndicator[];
categories_affected?: string[];
coupling_context?: V3AiTamperedResult['coupling_context'];
image_indicators?: string[];
image_evidence?: string;
model_used?: string;
transcript?: string;
}
export function toAiDisplayResult(
r: V3AiTamperedResult,
ctx: { inputType?: string; inputText?: string | null } = {},
): AiDisplayResult {
const out: AiDisplayResult = {
verdict: r.verdict,
ai_probability: r.ai_probability,
disclosure_detected: r.disclosure_detected,
disclosure_explicit: r.disclosure_explicit,
disclosure_text: r.disclosure_text,
};
if (r.indicators_detected && r.indicators_detected.length > 0) {
out.indicators_detected = r.indicators_detected;
out.categories_affected = r.categories_affected;
out.coupling_context = r.coupling_context;
}
const img = r.image_analysis as any;
if (img?.indicators && img.indicators.length > 0) {
out.image_indicators = img.indicators;
out.image_evidence = img.evidence;
out.model_used = img.model_used;
}
if (ctx.inputText && (ctx.inputType === 'audio' || ctx.inputType === 'video')) {
out.transcript = ctx.inputText;
}
return out;
}
interface Props {
result: AiDisplayResult;
}
export default function AiResults({ result }: Props) {
const { t, language } = useTranslation();
const isRo = language === 'ro';
const [indicatorsExpand, setIndicatorsExpand] = useState(false);
const probability = result.ai_probability || 0;
const verdictKey = (result.verdict || '').toUpperCase();
const verdictText = VERDICT_LABELS[verdictKey]?.[isRo ? 'ro' : 'en']
|| enumLabel(result.verdict || '');
const accent = probAccent(probability);
const Icon = probIcon(probability);
const indCount = result.indicators_detected?.length
|| result.indicators_found?.length
|| result.image_indicators?.length
|| 0;
const disclosureNote = result.disclosure_detected === true
? (isRo ? ' cu declarație AI' : ' with AI disclosure')
: result.disclosure_detected === false
? (isRo ? ' fără declarație AI' : ' without AI disclosure')
: '';
const tldr = isRo
? `Probabilitate ${probability}% de generare AI${disclosureNote}${indCount > 0 ? ` · ${indCount} ${indCount === 1 ? 'indicator detectat' : 'indicatori detectați'}` : ''}.`
: `${probability}% likelihood of AI generation${disclosureNote}${indCount > 0 ? ` · ${indCount} ${indCount === 1 ? 'indicator detected' : 'indicators detected'}` : ''}.`;
const totalIndicators = result.indicators_detected?.length ?? 0;
const visibleCount = indicatorsExpand ? totalIndicators : Math.min(DEFAULT_VISIBLE_INDICATORS, totalIndicators);
const visibleIndicators = result.indicators_detected?.slice(0, visibleCount) ?? [];
const remainingIndicators = totalIndicators - visibleCount;
const descriptorBits: string[] = [];
const confidence = (result.coupling_context as any)?.for_verdict?.confidence_level;
if (confidence) descriptorBits.push(`${isRo ? 'încredere' : 'confidence'} ${enumLabel(confidence).toLowerCase()}`);
if (result.disclosure_detected === true) descriptorBits.push(isRo ? 'cu declarație AI' : 'with AI disclosure');
if (result.disclosure_detected === false) descriptorBits.push(isRo ? 'fără declarație AI' : 'no AI disclosure');
return (
<View>
<HeadlineCard
accent={accent}
score={probability}
scoreLabel="% AI"
eyebrow={isRo ? 'Detectare AI' : 'AI detection'}
icon={Icon}
category={verdictText}
descriptor={descriptorBits.length ? descriptorBits.join(' · ') : undefined}
tldr={tldr}
/>
{(result.coupling_context || (result.categories_affected && result.categories_affected.length > 0) || result.model_used) ? (
<StatBlock>
<ChipsRow>
{(result.coupling_context as any)?.for_verdict?.undisclosed_ai && (
<Chip variant="warning">{isRo ? 'Conținut AI nedeclarat' : 'Undisclosed AI'}</Chip>
)}
{(result.coupling_context as any)?.for_verdict?.needs_manual_review && (
<Chip variant="warning">{isRo ? 'Necesită verificare manuală' : 'Needs manual review'}</Chip>
)}
{(result.categories_affected ?? []).map(cat => {
const cl = CATEGORY_LABELS[cat]?.[isRo ? 'ro' : 'en'] || cat;
return <Chip key={cat} variant="violet">{cat} · {cl}</Chip>;
})}
{result.model_used ? <Chip variant="neutral">{result.model_used}</Chip> : null}
</ChipsRow>
</StatBlock>
) : null}
{result.disclosure_detected !== undefined && (
<FindingCard
icon={result.disclosure_detected ? ShieldCheck : ShieldAlert}
accent={result.disclosure_detected ? 'success' : 'neutral'}
eyebrow={isRo ? 'Declarație AI' : 'AI disclosure'}
headline={
result.disclosure_detected
? (isRo
? (result.disclosure_explicit ? 'Declarație AI explicită' : 'Declarație AI implicită')
: (result.disclosure_explicit ? 'Explicit AI disclosure' : 'Implicit AI disclosure'))
: (isRo ? 'Nicio declarație de generare AI detectată' : 'No AI disclosure detected')
}
quote={result.disclosure_text || undefined}
/>
)}
{result.image_evidence && (
<FindingCard
icon={ImageIcon}
accent="info"
eyebrow={isRo ? 'Analiză imagine' : 'Image analysis'}
headline={isRo ? 'Sumar analiză' : 'Analysis summary'}
quote={result.image_evidence}
/>
)}
{result.transcript && (
<FindingCard
icon={FileText}
accent="info"
eyebrow={isRo ? 'Transcript' : 'Transcript'}
headline={isRo ? 'Conținut transcris' : 'Transcribed content'}
quote={result.transcript.length > 320 ? result.transcript.slice(0, 320) + '…' : result.transcript}
/>
)}
{result.indicators_found && result.indicators_found.length > 0 && (
<>
<SectionDivider
label={isRo ? 'Semnale detectate' : 'Signals detected'}
count={result.indicators_found.length}
/>
{result.indicators_found.map((ind, i) => (
<FindingCard key={i} icon={AlertCircle} accent="info" headline={ind} />
))}
</>
)}
{result.image_indicators && result.image_indicators.length > 0 && (
<>
<SectionDivider
label={isRo ? 'Indicatori imagine' : 'Image indicators'}
count={result.image_indicators.length}
/>
{result.image_indicators.map((ind, i) => (
<FindingCard key={i} icon={Sparkles} accent="warning" headline={ind} />
))}
</>
)}
{totalIndicators > 0 && (
<>
<SectionDivider
label={isRo ? 'Indicatori detectați' : 'Indicators detected'}
count={`${totalIndicators} ${isRo ? 'detectați' : 'detected'}${result.categories_affected?.length ? ` · ${result.categories_affected.join(' · ')}` : ''}`}
/>
{visibleIndicators.map(ind => {
const catLabel = CATEGORY_LABELS[ind.category]?.[isRo ? 'ro' : 'en'] || ind.category;
const eyebrowParts = [ind.category, catLabel, `${isRo ? 'încredere' : 'confidence'} ${ind.confidence}%`];
return (
<FindingCard
key={String(ind.id)}
icon={AlertCircle}
accent={indicatorAccent(ind.confidence)}
eyebrow={eyebrowParts.join(' · ')}
headline={localized(ind, 'indicator_name', 'name')}
quote={ind.evidence}
/>
);
})}
{remainingIndicators > 0 && (
<CollapseButton
onPress={() => setIndicatorsExpand(true)}
expanded={false}
leading={<Plus size={16} color="rgba(255,255,255,0.6)" strokeWidth={2} />}
label={
isRo
? `Vezi celelalți ${remainingIndicators} ${remainingIndicators === 1 ? 'indicator' : 'indicatori'}`
: `Show ${remainingIndicators} more ${remainingIndicators === 1 ? 'indicator' : 'indicators'}`
}
/>
)}
{indicatorsExpand && totalIndicators > DEFAULT_VISIBLE_INDICATORS && (
<CollapseButton
onPress={() => setIndicatorsExpand(false)}
expanded={true}
label={isRo ? 'Ascunde' : 'Hide'}
/>
)}
</>
)}
{(result.indicators_found?.length === 0
&& !result.indicators_detected
&& !result.image_indicators?.length) && (
<FindingCard
icon={ShieldCheck}
accent="success"
eyebrow={isRo ? 'Conținut curat' : 'Clean content'}
headline={isRo ? 'Niciun semnal de generare AI detectat' : 'No AI signals detected'}
/>
)}
</View>
);
}

View file

@ -0,0 +1,219 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, Linking, TouchableOpacity } from 'react-native';
import {
CheckSquare, XCircle, AlertTriangle, HelpCircle, Search, Timer, CheckCircle2, Plus,
} from 'lucide-react-native';
import type { LucideIcon } from 'lucide-react-native';
import {
HeadlineCard, FindingCard, SectionDivider, StatBlock, ChipsRow, Chip, CollapseButton,
} from '../editorial';
import type { FindingAccent } from '../../theme/colors';
import type { V3ClaimsResult, V3Claim } from '../../types/analysis';
import { localized, useTranslation, enumLabel, smoothEnums } from '../../i18n';
const DEFAULT_VISIBLE = 4;
const credAccent = (s: number) =>
s >= 60 ? '#22c55e' : s >= 40 ? '#eab308' : s >= 20 ? '#f97316' : '#ef4444';
const credIcon = (s: number, total: number): LucideIcon => {
if (total === 0) return HelpCircle;
if (s >= 60) return CheckSquare;
if (s >= 40) return HelpCircle;
if (s >= 20) return AlertTriangle;
return XCircle;
};
const claimIcon = (status: string, color?: string): LucideIcon => {
if (status === 'verified_false' || color === 'red') return XCircle;
if (status === 'verified_true' || color === 'green') return CheckCircle2;
return HelpCircle;
};
const claimAccent = (status: string, color?: string): FindingAccent => {
if (status === 'verified_false' || color === 'red') return 'critical';
if (status === 'verified_true' || color === 'green') return 'success';
return 'neutral';
};
const stanceFromString = (s: string): 'contradicts' | 'supports' | 'neutral' => {
const u = (s || '').toUpperCase();
if (u === 'CONTRADICTS') return 'contradicts';
if (u === 'SUPPORTS') return 'supports';
return 'neutral';
};
const sourceHostname = (url: string): string =>
(url || '').replace(/^https?:\/\/(www\.)?/, '').split('/')[0];
const buildTldr = (r: V3ClaimsResult, percent: number, isRo: boolean): string => {
const total = r.total_claims || r.claims_verified?.length || 0;
if (total === 0) {
return isRo
? 'Nicio afirmație verificabilă în acest conținut.'
: 'No verifiable claims found in this content.';
}
const parts: string[] = [];
if (r.verified_true > 0) parts.push(`${r.verified_true} ${isRo ? 'adevărate' : 'true'}`);
if (r.verified_false > 0) parts.push(`${r.verified_false} ${isRo ? 'false' : 'false'}`);
if (r.unverified > 0) parts.push(`${r.unverified} ${isRo ? 'neverificate' : 'unverified'}`);
if (r.opinions > 0) parts.push(`${r.opinions} ${isRo ? 'opinii' : 'opinions'}`);
return isRo
? `${total} ${total === 1 ? 'afirmație' : 'afirmații'} analizate (${parts.join(', ')}). Scor: ${percent}%.`
: `${total} ${total === 1 ? 'claim' : 'claims'} analyzed (${parts.join(', ')}). Score: ${percent}%.`;
};
interface Props {
result: V3ClaimsResult;
}
export default function ClaimsResults({ result }: Props) {
const { language } = useTranslation();
const isRo = language === 'ro';
const [expanded, setExpanded] = useState(false);
const rawCred = parseFloat(String(result.credibility_score)) || 0;
const credPercent = Math.round(rawCred > 1 ? rawCred : rawCred * 100);
const accent = credAccent(credPercent);
const Icon = credIcon(credPercent, result.total_claims);
const claims = result.claims_verified ?? [];
const visible = expanded ? claims.length : Math.min(DEFAULT_VISIBLE, claims.length);
const shown = claims.slice(0, visible);
const remaining = claims.length - visible;
const summaryParts = [
`${result.total_claims} ${isRo ? 'total' : 'total'}`,
result.verified_true > 0 ? `${result.verified_true} ${isRo ? 'adevărate' : 'true'}` : null,
result.verified_false > 0 ? `${result.verified_false} ${isRo ? 'false' : 'false'}` : null,
result.unverified > 0 ? `${result.unverified} ${isRo ? 'neverificate' : 'unverified'}` : null,
].filter(Boolean).join(' · ');
return (
<View>
<HeadlineCard
accent={accent}
score={credPercent}
scoreLabel={isRo ? '% CREDIBIL' : '% CREDIBLE'}
eyebrow={isRo ? 'Verificarea afirmațiilor' : 'Claim verification'}
icon={Icon}
category={smoothEnums(result.interpretation) || (isRo ? 'Analiză afirmații' : 'Claim analysis')}
descriptor={`${result.total_claims} ${isRo ? (result.total_claims === 1 ? 'afirmație' : 'afirmații') : (result.total_claims === 1 ? 'claim' : 'claims')}`}
tldr={buildTldr(result, credPercent, isRo)}
/>
{result.total_claims > 0 && (
<StatBlock>
<ChipsRow>
{result.verified_true > 0 && (
<Chip variant="success">{result.verified_true} {isRo ? 'adevărate' : 'verified true'}</Chip>
)}
{result.verified_false > 0 && (
<Chip variant="critical">{result.verified_false} {isRo ? 'false' : 'verified false'}</Chip>
)}
{result.unverified > 0 && (
<Chip variant="neutral">{result.unverified} {isRo ? 'neverificate' : 'unverified'}</Chip>
)}
{result.opinions > 0 && (
<Chip variant="info">{result.opinions} {isRo ? 'opinii' : 'opinions'}</Chip>
)}
{result.web_searches_made > 0 && (
<Chip variant="violet" leading={<Search size={12} color="#a78bfa" strokeWidth={2} />}>
{result.web_searches_made} {isRo ? 'căutări web' : 'web searches'}
</Chip>
)}
{result.total_duration_ms != null && result.total_duration_ms > 0 && (
<Chip variant="neutral" leading={<Timer size={12} color="#94a3b8" strokeWidth={2} />}>
{(result.total_duration_ms / 1000).toFixed(1)}s
</Chip>
)}
</ChipsRow>
</StatBlock>
)}
{claims.length > 0 && (
<>
<SectionDivider
label={isRo ? 'Verificarea afirmațiilor' : 'Claim verification'}
count={summaryParts}
/>
{shown.map((claim: V3Claim, i) => {
const Icon2 = claimIcon(claim.status, claim.status_color);
const accent2 = claimAccent(claim.status, claim.status_color);
const statusName = localized(claim, 'status_name') || enumLabel(claim.status || '');
const typeName = (claim as any).type_name || enumLabel(claim.type || '');
const eyebrowParts = [
statusName,
typeName,
(claim as any).priority ? `${isRo ? 'prioritate' : 'priority'} ${(claim as any).priority}` : null,
].filter(Boolean) as string[];
const sources = (claim.sources ?? []).slice(0, 6);
return (
<FindingCard
key={(claim as any).id || i}
icon={Icon2}
accent={accent2}
eyebrow={eyebrowParts.join(' · ')}
headline={smoothEnums(claim.text)}
quote={smoothEnums(claim.reasoning) || undefined}
source={
sources.length > 0 ? (
<View style={s.sourceRow}>
{(() => {
const dom = stanceFromString(sources[0]?.stance);
const stanceText = isRo
? (dom === 'contradicts' ? 'Contrazice' : dom === 'supports' ? 'Susține' : 'Neutru')
: (dom === 'contradicts' ? 'Contradicts' : dom === 'supports' ? 'Supports' : 'Neutral');
const stanceColor = dom === 'contradicts' ? '#ef4444' : dom === 'supports' ? '#22c55e' : '#94a3b8';
return (
<View style={[s.stancePill, { backgroundColor: `${stanceColor}22` }]}>
<Text style={[s.stanceText, { color: stanceColor }]}>{stanceText}</Text>
</View>
);
})()}
{sources.map((src, idx) => (
<React.Fragment key={idx}>
{idx > 0 && <Text style={s.sep}>·</Text>}
<TouchableOpacity onPress={() => Linking.openURL(src.url)}>
<Text style={s.link}>{sourceHostname(src.url)}</Text>
</TouchableOpacity>
</React.Fragment>
))}
</View>
) : undefined
}
/>
);
})}
{remaining > 0 && (
<CollapseButton
onPress={() => setExpanded(true)}
expanded={false}
leading={<Plus size={16} color="rgba(255,255,255,0.6)" strokeWidth={2} />}
label={
isRo
? `Vezi celelalte ${remaining} ${remaining === 1 ? 'afirmație' : 'afirmații'}`
: `Show ${remaining} more ${remaining === 1 ? 'claim' : 'claims'}`
}
/>
)}
{expanded && claims.length > DEFAULT_VISIBLE && (
<CollapseButton
onPress={() => setExpanded(false)}
expanded={true}
label={isRo ? 'Ascunde' : 'Hide'}
/>
)}
</>
)}
</View>
);
}
const s = StyleSheet.create({
sourceRow: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: 6 },
stancePill: { paddingHorizontal: 7, paddingVertical: 2, borderRadius: 4 },
stanceText: { fontSize: 9.5, fontWeight: '700', letterSpacing: 0.5, textTransform: 'uppercase' },
sep: { color: 'rgba(255,255,255,0.25)', fontSize: 12 },
link: { color: '#a78bfa', fontSize: 12.5, fontWeight: '500' },
});

View file

@ -0,0 +1,158 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import {
ShieldCheck, ShieldAlert, AlertTriangle, AlertOctagon, AlertCircle,
} from 'lucide-react-native';
import type { LucideIcon } from 'lucide-react-native';
import {
HeadlineCard, SectionDivider, FindingCard,
ScoreLine, ScoresBlock,
} from '../editorial';
import type { V3SourceAssessmentResult } from '../../types/analysis';
import { useTranslation, enumLabel } from '../../i18n';
import { ACCENT, FONT_WEIGHTS } from '../../theme/colors';
const trustAccent = (s: number) =>
s >= 70 ? '#22c55e' : s >= 40 ? '#eab308' : s >= 20 ? '#f97316' : '#ef4444';
const trustIcon = (s: number): LucideIcon => {
if (s >= 70) return ShieldCheck;
if (s >= 40) return ShieldAlert;
if (s >= 20) return AlertTriangle;
return AlertOctagon;
};
const barColor = (score: number) =>
score >= 70 ? '#22c55e' : score >= 40 ? '#f97316' : '#ef4444';
const buildTldr = (r: V3SourceAssessmentResult, isRo: boolean): string => {
const verdictTxt = enumLabel(r.verdict || '').toLowerCase();
const riskTxt = enumLabel(r.risk_level || '').toLowerCase();
if (isRo) {
return `Sursă ${verdictTxt} cu scor de încredere ${r.trust_score}/100${riskTxt ? `, risc ${riskTxt}` : ''}.`;
}
return `${verdictTxt.charAt(0).toUpperCase() + verdictTxt.slice(1)} source with trust score ${r.trust_score}/100${riskTxt ? ` · ${riskTxt} risk` : ''}.`;
};
interface Props {
result: V3SourceAssessmentResult;
}
export default function SourceResults({ result: r }: Props) {
const { language } = useTranslation();
const isRo = language === 'ro';
const accent = trustAccent(r.trust_score);
const Icon = trustIcon(r.trust_score);
const verdictLabel = enumLabel(r.verdict || '');
const pub = r.publication || ({} as any);
const auth = r.author || ({} as any);
const plat = r.platform || ({} as any);
const dom = r.domain || ({} as any);
const axes = [
{ name: isRo ? 'Publicație' : 'Publication', score: pub.score ?? 0 },
{ name: isRo ? 'Autor' : 'Author', score: auth.score ?? 0 },
{ name: isRo ? 'Platformă' : 'Platform', score: plat.score ?? 0 },
{ name: isRo ? 'Domeniu' : 'Domain', score: dom.score ?? 0 },
];
const flags: string[] = [];
if (!pub.confirmed) flags.push(isRo ? 'Publicație neverificată' : 'Publication unverified');
if (!auth.confirmed) flags.push(isRo ? 'Autor neidentificat' : 'Author unidentified');
if (!dom.name || dom.name === 'N/A') flags.push(isRo ? 'Fără domeniu' : 'No domain');
(r.red_flags ?? []).forEach(f => flags.push(f.replace(/_/g, ' ').toLowerCase()));
return (
<View>
<HeadlineCard
accent={accent}
score={r.trust_score}
scoreLabel={isRo ? '/ 100 ÎNCREDERE' : '/ 100 TRUST'}
eyebrow={isRo ? 'Evaluarea sursei' : 'Source assessment'}
icon={Icon}
category={verdictLabel}
descriptor={
[
r.risk_level && `${isRo ? 'Risc' : 'Risk'} ${enumLabel(r.risk_level).toLowerCase()}`,
pub.name ? pub.name : null,
].filter(Boolean).join(' · ')
}
tldr={buildTldr(r, isRo)}
/>
<SectionDivider
label={isRo ? 'Evaluarea sursei' : 'Source assessment'}
count={`${r.trust_score} / 100 · ${verdictLabel.toLowerCase()}`}
/>
{/* Single big finding card with the 4 axes inside */}
<FindingCard
icon={Icon as any}
accent={r.trust_score >= 70 ? 'success' : r.trust_score >= 40 ? 'warning' : 'critical'}
eyebrow={verdictLabel}
headline={
isRo
? `${verdictLabel} — încredere ${r.trust_score}/100${r.risk_level ? ` · risc ${enumLabel(r.risk_level).toLowerCase()}` : ''}`
: `${verdictLabel} — trust ${r.trust_score}/100${r.risk_level ? ` · ${enumLabel(r.risk_level).toLowerCase()} risk` : ''}`
}
/>
<View style={s.scoresWrap}>
<ScoresBlock>
{axes.map((a, i) => (
<ScoreLine
key={a.name}
name={a.name}
score={a.score}
color={barColor(a.score)}
delay={400 + i * 60}
/>
))}
</ScoresBlock>
{flags.length > 0 && (
<View style={s.flagsRow}>
<AlertCircle size={13} color="rgba(255,255,255,0.55)" strokeWidth={2} />
{flags.map((f, idx) => (
<React.Fragment key={idx}>
{idx > 0 && <Text style={s.sep}>·</Text>}
<Text style={s.flagText}>{f}</Text>
</React.Fragment>
))}
</View>
)}
</View>
</View>
);
}
const s = StyleSheet.create({
scoresWrap: {
paddingHorizontal: 18,
paddingVertical: 14,
backgroundColor: 'rgba(255,255,255,0.018)',
borderRadius: 16,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.04)',
gap: 14,
marginTop: -4,
marginBottom: 12,
},
flagsRow: {
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'center',
gap: 6,
marginTop: 6,
},
flagText: {
fontSize: 12.5,
color: 'rgba(255,255,255,0.55)',
letterSpacing: -0.05,
},
sep: {
color: 'rgba(255,255,255,0.25)',
fontSize: 12,
},
});

View file

@ -0,0 +1,183 @@
import React, { useState } from 'react';
import { View } from 'react-native';
import {
AlertOctagon, AlertTriangle, Info, ShieldCheck, AlertCircle, Cpu, Layers, Target, Plus,
} from 'lucide-react-native';
import type { LucideIcon } from 'lucide-react-native';
import {
HeadlineCard, FindingCard, SectionDivider, StatBlock, ChipsRow, Chip, CollapseButton,
} from '../editorial';
import type { FindingAccent } from '../../theme/colors';
import type { V3TechniquesResult, TechniqueDefinition } from '../../types/analysis';
import { formatTechniqueName } from '../../utils/formatTechniqueName';
import { localized, useTranslation, smoothEnums } from '../../i18n';
const DEFAULT_VISIBLE = 5;
const scoreAccent = (s: number) =>
s >= 70 ? '#ef4444' : s >= 40 ? '#f97316' : s >= 20 ? '#eab308' : '#22c55e';
const scoreIcon = (s: number): LucideIcon => {
if (s >= 70) return AlertOctagon;
if (s >= 40) return AlertTriangle;
if (s >= 20) return Info;
return ShieldCheck;
};
const scoreCategory = (s: number, isRo: boolean): string => {
if (s >= 70) return isRo ? 'Critic' : 'Critical';
if (s >= 40) return isRo ? 'Ridicat' : 'High';
if (s >= 20) return isRo ? 'Mediu' : 'Medium';
return isRo ? 'Scăzut' : 'Low';
};
const techIcon = (name: string): LucideIcon => {
const dim = (name?.split('.')[0] || '').toUpperCase();
if (dim === 'D3') return Cpu;
if (dim === 'D5') return Layers;
if (dim === 'D8') return Target;
return AlertCircle;
};
const techAccent = (severity: number): FindingAccent => {
if (severity >= 70) return 'critical';
if (severity >= 40) return 'warning';
return 'info';
};
const buildTldr = (r: V3TechniquesResult, isRo: boolean): string => {
const techCount = r.techniques_detected?.length ?? 0;
const dimCount = r.dimensions_affected?.length ?? 0;
const dims = (r.dimensions_affected ?? []).join(' · ');
if (techCount === 0) {
return isRo
? 'Nicio tehnică de manipulare detectată în acest conținut.'
: 'No manipulation techniques detected in this content.';
}
return isRo
? `${techCount} ${techCount === 1 ? 'tehnică' : 'tehnici'} de manipulare detectate în ${dimCount} ${dimCount === 1 ? 'dimensiune' : 'dimensiuni'}${dims ? ` (${dims})` : ''}.`
: `${techCount} manipulation ${techCount === 1 ? 'technique' : 'techniques'} detected across ${dimCount} ${dimCount === 1 ? 'dimension' : 'dimensions'}${dims ? ` (${dims})` : ''}.`;
};
interface Props {
result: V3TechniquesResult;
defMap?: Map<string, TechniqueDefinition>;
}
export default function TechniquesResults({ result, defMap }: Props) {
const { language } = useTranslation();
const isRo = language === 'ro';
const [expanded, setExpanded] = useState(false);
const score = result.manipulation_score ?? 0;
const accent = scoreAccent(score);
const Icon = scoreIcon(score);
const techCount = result.techniques_detected?.length ?? 0;
const dimCount = result.dimensions_affected?.length ?? 0;
const techs = result.techniques_detected ?? [];
const visible = expanded ? techs.length : Math.min(DEFAULT_VISIBLE, techs.length);
const shown = techs.slice(0, visible);
const remaining = techs.length - visible;
const warningFlags: string[] = (result.coupling_context as any)?.for_claims?.warning_flags ?? [];
return (
<View>
<HeadlineCard
accent={accent}
score={score}
scoreLabel={isRo ? '/ 100 SCOR' : '/ 100 SCORE'}
eyebrow={isRo ? 'Tehnici de manipulare' : 'Manipulation techniques'}
icon={Icon}
category={scoreCategory(score, isRo)}
descriptor={
dimCount > 0
? `${techCount} ${isRo ? (techCount === 1 ? 'tehnică' : 'tehnici') : (techCount === 1 ? 'technique' : 'techniques')} · ${dimCount} ${isRo ? (dimCount === 1 ? 'dimensiune' : 'dimensiuni') : (dimCount === 1 ? 'dimension' : 'dimensions')}`
: `${techCount} ${isRo ? (techCount === 1 ? 'tehnică' : 'tehnici') : (techCount === 1 ? 'technique' : 'techniques')}`
}
tldr={buildTldr(result, isRo)}
/>
{(score > 0 || dimCount > 0) && (
<StatBlock>
<ChipsRow>
{(result.dimensions_affected ?? []).map(dim => (
<Chip key={dim} variant="info">{dim}</Chip>
))}
{techCount > 0 && (
<Chip variant="violet">{techCount} {isRo ? 'tehnici detectate' : 'techniques detected'}</Chip>
)}
</ChipsRow>
</StatBlock>
)}
{techCount > 0 && (
<>
<SectionDivider
label={isRo ? 'Tehnici de manipulare' : 'Manipulation techniques'}
count={`${techCount} ${isRo ? 'detectate' : 'detected'}${dimCount > 0 ? ` · ${(result.dimensions_affected ?? []).join(' · ')}` : ''}`}
/>
{shown.map((tech, i) => {
const sev = tech.severity ?? (tech.intensity * 33);
const rawName = localized(tech, 'name') || `Technique #${tech.technique_id}`;
const def = defMap?.get(rawName);
const dimName = localized(tech, 'dimension_name', 'dimension');
const subdimName = localized(tech, 'subdimension_name', 'subdimension');
const eyebrowParts = [
(tech.name?.split('.')[0] || '').toUpperCase() || undefined,
dimName ? (subdimName ? `${dimName} · ${subdimName}` : dimName) : undefined,
tech.severity != null ? `${isRo ? 'severitate' : 'severity'} ${tech.severity}` : undefined,
].filter(Boolean) as string[];
return (
<FindingCard
key={i}
icon={techIcon(tech.name || '')}
accent={techAccent(sev)}
eyebrow={eyebrowParts.join(' · ')}
headline={formatTechniqueName(rawName)}
quote={smoothEnums(tech.evidence) || smoothEnums(def ? localized(def, 'description') : '') || undefined}
/>
);
})}
{remaining > 0 && (
<CollapseButton
onPress={() => setExpanded(true)}
expanded={false}
leading={<Plus size={16} color="rgba(255,255,255,0.6)" strokeWidth={2} />}
label={
isRo
? `Vezi celelalte ${remaining} ${remaining === 1 ? 'tehnică' : 'tehnici'}`
: `Show ${remaining} more ${remaining === 1 ? 'technique' : 'techniques'}`
}
/>
)}
{expanded && techs.length > DEFAULT_VISIBLE && (
<CollapseButton
onPress={() => setExpanded(false)}
expanded={true}
label={isRo ? 'Ascunde' : 'Hide'}
/>
)}
</>
)}
{techCount === 0 && (
<FindingCard
icon={ShieldCheck}
accent="success"
eyebrow={isRo ? 'Conținut curat' : 'Clean content'}
headline={isRo ? 'Nicio tehnică de manipulare detectată' : 'No manipulation techniques detected'}
/>
)}
{warningFlags.length > 0 && (
<FindingCard
icon={AlertTriangle}
accent="warning"
eyebrow={isRo ? 'Semnale adiționale' : 'Additional signals'}
headline={isRo ? 'Indicatori de manipulare la screening' : 'Manipulation signals at screening'}
quote={warningFlags.map(f => f.replace(/_/g, ' ')).join(' · ')}
/>
)}
</View>
);
}

View file

@ -0,0 +1,384 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, Linking, TouchableOpacity } from 'react-native';
import {
AlertOctagon, AlertTriangle, Info, ShieldCheck, OctagonX, BarChart3, Layers,
AlertCircle, Cpu, ExternalLink, ChevronDown, Quote, Users, Zap, UserX, ShieldQuestion, TrendingUp, Timer, XCircle, Tag,
} from 'lucide-react-native';
import type { LucideIcon } from 'lucide-react-native';
import {
HeadlineCard, FindingCard, SectionDivider, ActionCallout, StatBlock, ChipsRow, Chip,
CollapseButton, ScoreLine, ScoresBlock,
} from '../editorial';
import type { FindingAccent } from '../../theme/colors';
import { COLORS, ACCENT, getRiskColor as getRiskColorByName } from '../../theme/colors';
import type { V3Verdict, AnalysisSession } from '../../types/analysis';
import { useTranslation, enumLabel, localized, smoothEnums } from '../../i18n';
import TechniquesResults from './TechniquesResults';
import AiResults, { toAiDisplayResult } from './AiResults';
import ClaimsResults from './ClaimsResults';
import SourceResults from './SourceResults';
import type { TechniqueDefinition } from '../../types/analysis';
const SEVERITY_RANK: Record<string, number> = { critical: 0, warning: 1, info: 2 };
/** Hide debug strings the backend leaks into evidence_ref.quote
* (e.g. `ai_probability=85/100, verdict="LIKELY_AI"`, `Claims: [SKIPPED]`, `data_quality=full`). */
function cleanQuote(q: string | null | undefined): string | undefined {
if (!q) return undefined;
if (/\[SKIPPED\]/i.test(q)) return undefined;
if (/^\s*\w+\s*=\s*[\d"']/.test(q)) return undefined;
if (/data_quality\s*=/i.test(q)) return undefined;
return q;
}
interface KeyFinding {
type: string;
severity: string;
ro: string;
en: string;
evidence_ref?: { quote?: string; source_url?: string };
}
const TYPE_ICONS: Record<string, LucideIcon> = {
false_claim: BarChart3,
fabricated_quote: Quote,
manipulation: AlertCircle,
urgency: Zap,
imposter_content: UserX,
conspiracy: ShieldQuestion,
demographic: Users,
};
function findingIconAccent(f: KeyFinding): { icon: LucideIcon; accent: FindingAccent } {
const accent: FindingAccent =
f.severity === 'critical' ? 'critical'
: f.severity === 'warning' ? 'warning'
: 'info';
return { icon: TYPE_ICONS[f.type] || AlertCircle, accent };
}
function categoryIcon(color: string): LucideIcon {
if (color === 'red' || color === 'darkred') return AlertOctagon;
if (color === 'orange') return AlertTriangle;
if (color === 'yellow') return Info;
if (color === 'green' || color === 'lightgreen') return ShieldCheck;
return Info;
}
function actionEyebrow(severity: string, isRo: boolean): string {
if (severity === 'critical') return isRo ? 'NU DISTRIBUI' : 'DO NOT SHARE';
if (severity === 'warning') return isRo ? 'CITEȘTE CRITIC' : 'READ CRITICALLY';
return isRo ? 'DE REȚINUT' : 'NOTE';
}
function safeHostname(url: string): string {
try {
return new URL(url).hostname;
} catch {
return url;
}
}
interface Props {
verdict: V3Verdict;
fullResult: Partial<AnalysisSession> | null;
techniqueDefMap?: Map<string, TechniqueDefinition>;
totalDuration?: number | null;
}
export default function Verdict({ verdict, fullResult, techniqueDefMap, totalDuration }: Props) {
const { language } = useTranslation();
const isRo = language === 'ro';
const [secondaryOpen, setSecondaryOpen] = useState(false);
const [detailsOpen, setDetailsOpen] = useState(false);
const summary = (verdict.context_summary as any)?.verdict_summary;
const accentName = verdict.risk_category_color || 'red';
const accent = accentName.startsWith('#') ? accentName : getRiskColorByName(accentName);
const sortedFindings: KeyFinding[] = React.useMemo(() => {
if (!summary?.key_findings) return [];
return [...summary.key_findings].sort(
(a: KeyFinding, b: KeyFinding) => (SEVERITY_RANK[a.severity] ?? 3) - (SEVERITY_RANK[b.severity] ?? 3),
);
}, [summary]);
const dominant = sortedFindings[0];
const rest = sortedFindings.slice(1);
const CatIcon = categoryIcon(verdict.risk_category_color || '');
const techniquesCount = (verdict.context_summary as any)?.techniques_detected ?? 0;
const falseClaims = (verdict.context_summary as any)?.claims_false ?? 0;
const elapsedSec = totalDuration ? `${(totalDuration / 1000).toFixed(1)}s` : null;
const viralityScore = verdict.virality_score;
const viralityLevel = verdict.virality_level;
const scoreEntries = [
{ name: isRo ? 'Manipulare' : 'Manipulation', value: verdict.score_manipulation ?? null },
{ name: 'Claims', value: verdict.score_claims ?? null },
{ name: isRo ? 'AI generation' : 'AI generation', value: verdict.score_ai ?? null },
{ name: isRo ? 'Sursă' : 'Source', value: verdict.score_source ?? null },
];
return (
<View>
{summary ? (
<>
{/* TIER 1 — HEADLINE */}
<HeadlineCard
accent={accent}
score={verdict.risk_score}
scoreLabel={`/ 100 ${isRo ? 'RISC' : 'RISK'}`}
eyebrow={isRo ? 'Risc analizat' : 'Risk analyzed'}
icon={CatIcon}
category={enumLabel(verdict.risk_category || '')}
descriptor={[
verdict.risk_level ? enumLabel(verdict.risk_level) : null,
verdict.confidence != null ? `${isRo ? 'Certitudine' : 'Confidence'} ${verdict.confidence}%` : null,
].filter(Boolean).join(' · ')}
tldr={smoothEnums(isRo ? summary.tl_dr_ro : summary.tl_dr_en)}
/>
{/* TIER 2 — ACTION CALLOUT */}
{(summary.what_to_do_ro || summary.what_to_do_en) && (
<ActionCallout
accent={accent}
icon={OctagonX}
eyebrow={actionEyebrow(dominant?.severity || 'info', isRo)}
headline={
isRo
? (dominant?.severity === 'critical' ? 'Nu distribui acest articol' : 'Citește cu atenție')
: (dominant?.severity === 'critical' ? 'Do not share this article' : 'Read carefully')
}
body={smoothEnums(isRo ? summary.what_to_do_ro : summary.what_to_do_en)}
/>
)}
{/* TIER 3 — DOMINANT FINDING */}
{dominant && (() => {
const { icon, accent: a } = findingIconAccent(dominant);
return (
<>
<SectionDivider label={isRo ? 'Cea mai gravă problemă' : 'Top issue'} />
<FindingCard
dominant
icon={icon}
accent={a}
headline={smoothEnums(isRo ? dominant.ro : dominant.en)}
quote={smoothEnums(cleanQuote(dominant.evidence_ref?.quote)) || undefined}
source={
dominant.evidence_ref?.source_url ? (
<View style={s.sourceRow}>
<ExternalLink size={13} color="rgba(255,255,255,0.45)" strokeWidth={2} />
<Text style={s.sourceText}>{isRo ? 'Verificat de' : 'Verified by'}</Text>
<TouchableOpacity onPress={() => Linking.openURL(dominant.evidence_ref!.source_url!)}>
<Text style={s.link}>{safeHostname(dominant.evidence_ref!.source_url!)}</Text>
</TouchableOpacity>
</View>
) : undefined
}
/>
{rest.length > 0 && (
<>
<CollapseButton
onPress={() => setSecondaryOpen(o => !o)}
expanded={secondaryOpen}
leading={<Layers size={16} color="rgba(255,255,255,0.6)" strokeWidth={2} />}
label={
secondaryOpen
? (isRo ? 'Ascunde celelalte semne' : 'Hide other signals')
: (isRo
? `Vezi ${rest.length} ${rest.length === 1 ? 'alt semn' : 'alte semne'}`
: `Show ${rest.length} more ${rest.length === 1 ? 'signal' : 'signals'}`)
}
/>
{secondaryOpen && (
<>
<SectionDivider
label={isRo ? 'Alte semne' : 'Other signals'}
count={rest.length}
/>
{rest.map((f, idx) => {
const { icon: ic, accent: ac } = findingIconAccent(f);
return (
<FindingCard
key={idx}
icon={ic}
accent={ac}
headline={smoothEnums(isRo ? f.ro : f.en)}
quote={smoothEnums(cleanQuote(f.evidence_ref?.quote)) || undefined}
source={
f.evidence_ref?.source_url ? (
<View style={s.sourceRow}>
<ExternalLink size={13} color="rgba(255,255,255,0.45)" strokeWidth={2} />
<TouchableOpacity onPress={() => Linking.openURL(f.evidence_ref!.source_url!)}>
<Text style={s.link}>{safeHostname(f.evidence_ref!.source_url!)}</Text>
</TouchableOpacity>
</View>
) : (
<View style={s.sourceRow}>
<Tag size={13} color="rgba(255,255,255,0.45)" strokeWidth={2} />
<Text style={s.sourceText}>{f.type.replace(/_/g, ' ')}</Text>
</View>
)
}
/>
);
})}
</>
)}
</>
)}
</>
);
})()}
{/* TIER 4 — TECHNICAL DETAILS */}
<CollapseButton
onPress={() => setDetailsOpen(o => !o)}
expanded={detailsOpen}
leading={<BarChart3 size={16} color="rgba(255,255,255,0.6)" strokeWidth={2} />}
label={isRo ? 'Detalii tehnice' : 'Technical details'}
/>
{detailsOpen && (
<>
<StatBlock>
<ScoresBlock>
{scoreEntries.map((entry, i) => (
entry.value != null ? (
<ScoreLine
key={entry.name}
name={entry.name}
score={entry.value}
color={entry.value >= 70 ? '#ef4444' : entry.value >= 40 ? '#f97316' : '#22c55e'}
delay={200 + i * 80}
/>
) : (
<View key={entry.name} style={s.skippedRow}>
<Text style={s.skippedName}>{entry.name}</Text>
<View style={s.skippedBar} />
<Text style={s.skippedDash}></Text>
</View>
)
))}
</ScoresBlock>
<ChipsRow>
{viralityScore != null && (
<Chip
variant={viralityScore >= 50 ? 'critical' : viralityScore >= 25 ? 'warning' : 'success'}
leading={<TrendingUp size={12} color={viralityScore >= 50 ? ACCENT.critical : viralityScore >= 25 ? ACCENT.warning : ACCENT.success} strokeWidth={2} />}
>
Virality {viralityScore}{viralityLevel ? ` ${enumLabel(viralityLevel)}` : ''}
</Chip>
)}
{techniquesCount > 0 && (
<Chip variant="violet" leading={<Layers size={12} color={ACCENT.violet} strokeWidth={2} />}>
{techniquesCount} {isRo ? 'tehnici' : 'techniques'}
</Chip>
)}
{falseClaims > 0 && (
<Chip variant="critical" leading={<XCircle size={12} color={ACCENT.critical} strokeWidth={2} />}>
{falseClaims} {isRo ? 'false' : 'false claims'}
</Chip>
)}
{elapsedSec && (
<Chip variant="neutral" leading={<Timer size={12} color={ACCENT.neutral} strokeWidth={2} />}>
{elapsedSec}
</Chip>
)}
</ChipsRow>
</StatBlock>
{fullResult?.ai_tampered && (
<AiResults result={toAiDisplayResult(fullResult.ai_tampered)} />
)}
{fullResult?.source_assessment && (
<SourceResults result={fullResult.source_assessment} />
)}
{fullResult?.techniques && (
<TechniquesResults result={fullResult.techniques} defMap={techniqueDefMap} />
)}
{fullResult?.claims && <ClaimsResults result={fullResult.claims} />}
</>
)}
</>
) : (
// ── LEGACY FALLBACK ──
<>
<HeadlineCard
accent={accent}
score={verdict.risk_score}
scoreLabel={`/ 100 ${isRo ? 'RISC' : 'RISK'}`}
eyebrow={isRo ? 'Risc analizat' : 'Risk analyzed'}
icon={CatIcon}
category={enumLabel(verdict.risk_category || '')}
descriptor={
verdict.confidence != null
? `${isRo ? 'Certitudine' : 'Confidence'} ${verdict.confidence}%`
: undefined
}
/>
{(verdict.explanation_en || verdict.explanation_ro) && (
<View style={s.legacyExpl}>
<Text style={s.legacyText}>{smoothEnums(localized(verdict, 'explanation'))}</Text>
</View>
)}
</>
)}
</View>
);
}
const s = StyleSheet.create({
sourceRow: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
gap: 6,
},
sourceText: {
color: 'rgba(255,255,255,0.55)',
fontSize: 12.5,
},
link: {
color: '#a78bfa',
fontSize: 12.5,
fontWeight: '500',
},
skippedRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
skippedName: {
fontSize: 13,
color: 'rgba(255,255,255,0.7)',
width: 100,
},
skippedBar: {
flex: 1,
height: 4,
},
skippedDash: {
fontSize: 13,
color: 'rgba(255,255,255,0.28)',
width: 40,
textAlign: 'right',
},
legacyExpl: {
paddingHorizontal: 18,
paddingVertical: 14,
backgroundColor: 'rgba(255,255,255,0.025)',
borderRadius: 16,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.06)',
},
legacyText: {
fontFamily: 'Merriweather-Italic',
fontSize: 14,
lineHeight: 22,
color: 'rgba(255,255,255,0.7)',
},
});

View file

@ -0,0 +1,6 @@
export { default as TechniquesResults } from './TechniquesResults';
export { default as AiResults, toAiDisplayResult } from './AiResults';
export type { AiDisplayResult } from './AiResults';
export { default as ClaimsResults } from './ClaimsResults';
export { default as SourceResults } from './SourceResults';
export { default as Verdict } from './Verdict';

View file

@ -0,0 +1,105 @@
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import {
COLORS,
GRADIENTS,
SPACING,
RADIUS,
FONT_SIZES,
FONT_WEIGHTS,
LAYOUT,
} from '../../theme/colors';
import type { RootStackParamList } from '../../navigation/AppNavigator';
import { useTranslation } from '../../i18n';
export default function ChangePasswordSection() {
const { t } = useTranslation();
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
return (
<View style={styles.card}>
<View style={styles.headerRow}>
<View style={styles.iconCircle}>
<Text style={styles.iconText}>P</Text>
</View>
<View style={styles.textContainer}>
<Text style={styles.title}>{t('changePassword')}</Text>
<Text style={styles.subtitle}>{t('updatePassword')}</Text>
</View>
</View>
<TouchableOpacity
activeOpacity={0.8}
onPress={() => navigation.navigate('ChangePassword')}
accessibilityRole="button"
accessibilityLabel={t('changePassword')}
>
<LinearGradient
colors={[...GRADIENTS.button.colors] as [string, string]}
start={GRADIENTS.button.start}
end={GRADIENTS.button.end}
style={styles.button}
>
<Text style={styles.buttonText} maxFontSizeMultiplier={1.5}>{t('changePassword')}</Text>
</LinearGradient>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: COLORS.bg.card,
borderWidth: 1,
borderColor: COLORS.border.accent,
borderRadius: RADIUS.xxl,
padding: LAYOUT.CARD_PADDING,
marginBottom: SPACING.md,
},
headerRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: SPACING.lg,
},
iconCircle: {
width: LAYOUT.ICON_CIRCLE_SIZE,
height: LAYOUT.ICON_CIRCLE_SIZE,
borderRadius: RADIUS.full,
backgroundColor: COLORS.glass.purple10,
justifyContent: 'center',
alignItems: 'center',
marginRight: SPACING.md,
},
iconText: {
color: COLORS.brand.primary,
fontSize: FONT_SIZES.xl,
fontWeight: FONT_WEIGHTS.bold,
},
textContainer: {
flex: 1,
},
title: {
color: COLORS.text.primary,
fontSize: FONT_SIZES.base,
fontWeight: FONT_WEIGHTS.bold,
},
subtitle: {
color: COLORS.text.hint,
fontSize: FONT_SIZES.xs,
marginTop: 2,
},
button: {
borderRadius: RADIUS.xl,
paddingVertical: SPACING.sm + 4,
alignItems: 'center',
justifyContent: 'center',
},
buttonText: {
color: COLORS.text.primary,
fontSize: FONT_SIZES.sm,
fontWeight: FONT_WEIGHTS.bold,
},
});

View file

@ -0,0 +1,98 @@
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { COLORS, SPACING, RADIUS, FONT_SIZES, FONT_WEIGHTS } from '../../theme/colors';
import { useLanguageStore, useTranslation } from '../../i18n';
import type { Language } from '../../i18n';
const LANGUAGES: { key: Language; labelKey: 'english' | 'romanian'; flag: string }[] = [
{ key: 'en', labelKey: 'english', flag: '🇬🇧' },
{ key: 'ro', labelKey: 'romanian', flag: '🇷🇴' },
];
export default function LanguageSection() {
const { t } = useTranslation();
const { language, setLanguage } = useLanguageStore();
return (
<View style={styles.card}>
<Text style={styles.title}>{t('language')}</Text>
<Text style={styles.subtitle}>{t('languageSubtitle')}</Text>
<View style={styles.optionsRow}>
{LANGUAGES.map((lang) => {
const selected = language === lang.key;
return (
<TouchableOpacity
key={lang.key}
style={[styles.option, selected && styles.optionSelected]}
onPress={() => setLanguage(lang.key)}
activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel={t(lang.labelKey)}
accessibilityState={{ selected }}
>
<Text style={styles.flag}>{lang.flag}</Text>
<Text style={[styles.optionText, selected && styles.optionTextSelected]}>
{t(lang.labelKey)}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: COLORS.bg.card,
borderWidth: 1,
borderColor: COLORS.border.accent,
borderRadius: RADIUS.xxl,
padding: SPACING.lg,
marginBottom: SPACING.md,
},
title: {
color: COLORS.text.primary,
fontSize: FONT_SIZES.lg,
fontWeight: FONT_WEIGHTS.bold,
marginBottom: SPACING.xs,
},
subtitle: {
color: COLORS.text.secondary,
fontSize: FONT_SIZES.sm,
marginBottom: SPACING.md,
},
optionsRow: {
flexDirection: 'row',
gap: SPACING.md,
},
option: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: SPACING.sm,
backgroundColor: COLORS.glass.white5,
borderWidth: 1,
borderColor: COLORS.border.secondary,
borderRadius: RADIUS.lg,
paddingVertical: SPACING.md,
},
optionSelected: {
borderColor: COLORS.brand.primary,
backgroundColor: COLORS.glass.purple10,
},
flag: {
fontSize: FONT_SIZES.xl,
color: COLORS.text.primary,
},
optionText: {
color: COLORS.text.secondary,
fontSize: FONT_SIZES.base,
fontWeight: FONT_WEIGHTS.bold,
},
optionTextSelected: {
color: COLORS.brand.primaryLight,
},
});

View file

@ -0,0 +1,134 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import {
COLORS,
GRADIENTS,
SPACING,
RADIUS,
FONT_SIZES,
FONT_WEIGHTS,
LAYOUT,
} from '../../theme/colors';
import type { UserProfile } from '../../types/auth';
import { useTranslation } from '../../i18n';
interface ProfileSectionProps {
profile: UserProfile | null;
}
export default function ProfileSection({ profile }: ProfileSectionProps) {
const { t } = useTranslation();
const initials = profile
? `${profile.firstName?.[0] || ''}${profile.lastName?.[0] || ''}`.toUpperCase()
: '?';
return (
<View style={styles.card}>
<Text style={styles.sectionTitle}>{t('profile')}</Text>
<View style={styles.avatarRow}>
<LinearGradient
colors={[...GRADIENTS.avatar.colors] as [string, string, ...string[]]}
start={GRADIENTS.avatar.start}
end={GRADIENTS.avatar.end}
style={styles.avatar}
>
<Text style={styles.avatarText}>{initials}</Text>
</LinearGradient>
<View style={styles.avatarInfo}>
<Text style={styles.name}>
{profile ? `${profile.firstName} ${profile.lastName}` : t('loading')}
</Text>
<Text style={styles.email}>{profile?.email || ''}</Text>
</View>
</View>
<View style={styles.fieldsContainer}>
<View style={styles.fieldRow}>
<Text style={styles.fieldLabel}>{t('username')}</Text>
<Text style={styles.fieldValue}>{profile?.email || '—'}</Text>
</View>
<View style={styles.fieldRow}>
<Text style={styles.fieldLabel}>{t('firstName')}</Text>
<Text style={styles.fieldValue}>{profile?.firstName || '—'}</Text>
</View>
<View style={styles.fieldRow}>
<Text style={styles.fieldLabel}>{t('lastName')}</Text>
<Text style={styles.fieldValue}>{profile?.lastName || '—'}</Text>
</View>
<View style={styles.fieldRow}>
<Text style={styles.fieldLabel}>{t('email')}</Text>
<Text style={styles.fieldValue}>{profile?.email || '—'}</Text>
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: COLORS.bg.card,
borderWidth: 1,
borderColor: COLORS.border.accent,
borderRadius: RADIUS.xxl,
padding: LAYOUT.CARD_PADDING,
marginBottom: SPACING.md,
},
sectionTitle: {
color: COLORS.text.primary,
fontSize: FONT_SIZES.lg,
fontWeight: FONT_WEIGHTS.bold,
marginBottom: SPACING.md,
},
avatarRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: SPACING.lg,
},
avatar: {
width: LAYOUT.AVATAR_SIZE_LARGE,
height: LAYOUT.AVATAR_SIZE_LARGE,
borderRadius: RADIUS.full,
justifyContent: 'center',
alignItems: 'center',
marginRight: SPACING.md,
},
avatarText: {
color: COLORS.text.primary,
fontSize: FONT_SIZES.xl,
fontWeight: FONT_WEIGHTS.bold,
},
avatarInfo: {
flex: 1,
},
name: {
color: COLORS.text.primary,
fontSize: FONT_SIZES.base,
fontWeight: FONT_WEIGHTS.bold,
},
email: {
color: COLORS.text.secondary,
fontSize: FONT_SIZES.sm,
marginTop: 2,
},
fieldsContainer: {
gap: SPACING.sm,
},
fieldRow: {
backgroundColor: COLORS.glass.white5,
borderRadius: RADIUS.lg,
paddingHorizontal: SPACING.md,
paddingVertical: SPACING.sm + 4,
},
fieldLabel: {
color: COLORS.text.hint,
fontSize: FONT_SIZES.xs,
marginBottom: 2,
},
fieldValue: {
color: COLORS.text.primary,
fontSize: FONT_SIZES.sm,
fontWeight: FONT_WEIGHTS.bold,
},
});

View file

@ -0,0 +1,101 @@
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import {
COLORS,
SPACING,
RADIUS,
FONT_SIZES,
FONT_WEIGHTS,
LAYOUT,
getBadgeBgColor,
} from '../../theme/colors';
import { useSignOut } from '../../hooks/useSignOut';
import { useTranslation } from '../../i18n';
export default function SignOutSection() {
const { t } = useTranslation();
const handleSignOut = useSignOut();
return (
<View style={styles.card}>
<View style={styles.headerRow}>
<View style={styles.iconCircle}>
<Text style={styles.iconText}></Text>
</View>
<View style={styles.textContainer}>
<Text style={styles.title}>{t('signOut')}</Text>
<Text style={styles.subtitle}>{t('signOutSubtitle')}</Text>
</View>
</View>
<TouchableOpacity
activeOpacity={0.8}
onPress={handleSignOut}
style={styles.button}
accessibilityRole="button"
accessibilityLabel={t('signOut')}
>
<Text style={styles.buttonText} maxFontSizeMultiplier={1.5}>
{t('signOut')}
</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: COLORS.bg.card,
borderWidth: 1,
borderColor: getBadgeBgColor(COLORS.status.red, 0.35),
borderRadius: RADIUS.xxl,
padding: LAYOUT.CARD_PADDING,
marginBottom: SPACING.md,
},
headerRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: SPACING.lg,
},
iconCircle: {
width: LAYOUT.ICON_CIRCLE_SIZE,
height: LAYOUT.ICON_CIRCLE_SIZE,
borderRadius: RADIUS.full,
backgroundColor: getBadgeBgColor(COLORS.status.red, 0.12),
justifyContent: 'center',
alignItems: 'center',
marginRight: SPACING.md,
},
iconText: {
color: COLORS.status.red,
fontSize: FONT_SIZES.xl,
fontWeight: FONT_WEIGHTS.bold,
},
textContainer: {
flex: 1,
},
title: {
color: COLORS.text.primary,
fontSize: FONT_SIZES.base,
fontWeight: FONT_WEIGHTS.bold,
},
subtitle: {
color: COLORS.text.hint,
fontSize: FONT_SIZES.xs,
marginTop: 2,
},
button: {
borderRadius: RADIUS.xl,
paddingVertical: SPACING.sm + 4,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: getBadgeBgColor(COLORS.status.red, 0.5),
backgroundColor: getBadgeBgColor(COLORS.status.red, 0.12),
},
buttonText: {
color: COLORS.status.red,
fontSize: FONT_SIZES.sm,
fontWeight: FONT_WEIGHTS.bold,
},
});

View file

@ -0,0 +1,174 @@
import React from 'react';
import { View, Text, Switch, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import {
COLORS,
SPACING,
RADIUS,
FONT_SIZES,
FONT_WEIGHTS,
LAYOUT,
} from '../../theme/colors';
import type { SocialToggles } from '../../utils/socialShareStorage';
import { useTranslation } from '../../i18n';
interface SocialShareSectionProps {
toggles: SocialToggles;
onToggle: (platform: keyof SocialToggles) => void;
}
const SOCIAL_PLATFORMS: {
key: keyof SocialToggles;
name: string;
description: string;
icon: string;
colors: [string, string];
}[] = [
{
key: 'facebook',
name: 'Facebook',
description: 'Auto-analyze shared Facebook links',
icon: 'f',
colors: [COLORS.social.facebookStart, COLORS.social.facebookEnd],
},
{
key: 'x',
name: 'X (Twitter)',
description: 'Auto-analyze shared X/Twitter links',
icon: 'X',
colors: [COLORS.social.twitterStart, COLORS.social.twitterEnd],
},
{
key: 'tiktok',
name: 'TikTok',
description: 'Auto-analyze shared TikTok links',
icon: 'T',
colors: [COLORS.social.tiktokStart, COLORS.social.tiktokEnd],
},
];
export default function SocialShareSection({ toggles, onToggle }: SocialShareSectionProps) {
const { t } = useTranslation();
const platformLabels: Record<keyof SocialToggles, { name: string; description: string }> = {
facebook: { name: t('facebook'), description: t('autoAnalyzeFacebook') },
x: { name: t('xTwitter'), description: t('autoAnalyzeX') },
tiktok: { name: t('tiktok'), description: t('autoAnalyzeTiktok') },
};
return (
<View style={styles.card}>
<Text style={styles.sectionTitle}>{t('socialShare')}</Text>
<Text style={styles.sectionSubtitle}>
{t('socialShareDesc')}
</Text>
<View style={styles.togglesList}>
{SOCIAL_PLATFORMS.map((platform) => (
<View key={platform.key} style={styles.toggleRow}>
<LinearGradient
colors={platform.colors}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.iconCircle}
>
<Text style={styles.iconText}>{platform.icon}</Text>
</LinearGradient>
<View style={styles.toggleTextContainer}>
<Text style={styles.toggleName}>{platformLabels[platform.key].name}</Text>
<Text style={styles.toggleDescription}>{platformLabels[platform.key].description}</Text>
</View>
<Switch
value={toggles[platform.key]}
onValueChange={() => onToggle(platform.key)}
accessibilityRole="switch"
accessibilityLabel={platformLabels[platform.key].description}
accessibilityState={{ checked: toggles[platform.key] }}
trackColor={{
false: COLORS.glass.white10,
true: COLORS.glass.purple35,
}}
thumbColor={toggles[platform.key] ? COLORS.brand.primary : COLORS.text.hint}
/>
</View>
))}
</View>
<View style={styles.noteContainer}>
<Text style={styles.noteText}>
{t('socialShareNote')}
</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: COLORS.bg.card,
borderWidth: 1,
borderColor: COLORS.border.accent,
borderRadius: RADIUS.xxl,
padding: LAYOUT.CARD_PADDING,
marginBottom: SPACING.md,
},
sectionTitle: {
color: COLORS.text.primary,
fontSize: FONT_SIZES.lg,
fontWeight: FONT_WEIGHTS.bold,
marginBottom: SPACING.xs,
},
sectionSubtitle: {
color: COLORS.text.hint,
fontSize: FONT_SIZES.xs,
lineHeight: 18,
marginBottom: SPACING.lg,
},
togglesList: {
gap: SPACING.md,
},
toggleRow: {
flexDirection: 'row',
alignItems: 'center',
},
iconCircle: {
width: 40,
height: 40,
borderRadius: RADIUS.full,
justifyContent: 'center',
alignItems: 'center',
marginRight: SPACING.md,
},
iconText: {
color: COLORS.text.primary,
fontSize: FONT_SIZES.lg,
fontWeight: FONT_WEIGHTS.bold,
},
toggleTextContainer: {
flex: 1,
marginRight: SPACING.sm,
},
toggleName: {
color: COLORS.text.primary,
fontSize: FONT_SIZES.sm,
fontWeight: FONT_WEIGHTS.bold,
},
toggleDescription: {
color: COLORS.text.hint,
fontSize: FONT_SIZES.xs,
marginTop: 1,
},
noteContainer: {
backgroundColor: COLORS.glass.white5,
borderRadius: RADIUS.lg,
padding: SPACING.md,
marginTop: SPACING.lg,
},
noteText: {
color: COLORS.text.muted,
fontSize: FONT_SIZES.xs,
lineHeight: 18,
},
});

View file

@ -0,0 +1,247 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import {
COLORS,
SPACING,
RADIUS,
FONT_SIZES,
FONT_WEIGHTS,
LAYOUT,
} from '../../theme/colors';
import type { UserProfile } from '../../types/auth';
import { useTranslation } from '../../i18n';
interface SubscriptionSectionProps {
profile: UserProfile | null;
usageStats: any;
creditsData: { credits_remained: number; credits_spent: number } | null;
}
export default function SubscriptionSection({ profile, usageStats, creditsData }: SubscriptionSectionProps) {
const { t } = useTranslation();
const plan = usageStats?.plan as any;
const sub = usageStats?.subscription as any;
const planName = plan?.name || 'Free';
const isActive = sub?.isActive ?? sub?.is_active ?? false;
const price = plan?.priceAmount ?? plan?.price_amount ?? 0;
// Credits from auth/credits endpoint (authoritative source)
const creditsRemaining = creditsData?.credits_remained ?? profile?.creditsRemained ?? 0;
const creditsSpent = creditsData?.credits_spent ?? profile?.creditsSpent ?? 0;
const creditsTotal = creditsRemaining + creditsSpent;
const creditPercent = creditsTotal > 0 ? (creditsSpent / creditsTotal) * 100 : 0;
const creditsPerCycle = plan?.creditsPerCycle ?? plan?.credits_per_cycle ?? creditsTotal;
const maxImages = plan?.maxImages ?? plan?.max_images ?? null;
const maxVideoMinutes = plan?.maxVideoMinutes ?? plan?.max_video_minutes ?? null;
const storageLimitGb = plan?.storageLimitGb ?? plan?.storage_limit_gb ?? null;
const creditBarColor =
creditPercent > 90 ? COLORS.status.red :
creditPercent > 75 ? COLORS.status.yellow :
COLORS.brand.accentCyan;
return (
<View style={styles.card}>
<Text style={styles.sectionTitle}>{t('subscriptionCredits')}</Text>
{/* Plan info row */}
<View style={styles.planRow}>
<View style={styles.planBadge}>
<Text style={styles.planBadgeText}>{planName}</Text>
</View>
<View style={[styles.statusDot, { backgroundColor: isActive ? COLORS.status.green : COLORS.status.red }]} />
<Text style={styles.statusText}>{isActive ? t('active') : t('inactive')}</Text>
<Text style={styles.price}>${price}/mo</Text>
</View>
{/* Credits */}
<View style={styles.creditsContainer}>
<View style={styles.creditsHeader}>
<Text style={styles.creditsLabel}>{t('credits')}</Text>
<Text style={styles.creditsValue}>
{creditsRemaining} / {creditsTotal}
</Text>
</View>
<View style={styles.progressBg}>
<View
style={[
styles.progressFill,
{
width: `${Math.min(creditPercent, 100)}%`,
backgroundColor: creditBarColor,
},
]}
/>
</View>
<View style={styles.creditsDetails}>
<View style={styles.detailItem}>
<Text style={styles.detailLabel}>{t('remaining')}</Text>
<Text style={styles.detailValue}>{creditsRemaining}</Text>
</View>
<View style={styles.detailItem}>
<Text style={styles.detailLabel}>{t('total')}</Text>
<Text style={styles.detailValue}>{creditsTotal}</Text>
</View>
<View style={styles.detailItem}>
<Text style={styles.detailLabel}>{t('spent')}</Text>
<Text style={styles.detailValue}>{creditsSpent}</Text>
</View>
</View>
</View>
{/* Plan limits */}
<View style={styles.limitsContainer}>
<Text style={styles.limitsTitle}>{t('planLimits')}</Text>
<View style={styles.limitsGrid}>
<View style={styles.limitItem}>
<Text style={styles.limitLabel}>{t('creditsCycle')}</Text>
<Text style={styles.limitValue}>{creditsPerCycle || '—'}</Text>
</View>
<View style={styles.limitItem}>
<Text style={styles.limitLabel}>{t('maxImages')}</Text>
<Text style={styles.limitValue}>{maxImages ?? '—'}</Text>
</View>
<View style={styles.limitItem}>
<Text style={styles.limitLabel}>{t('videoMinutes')}</Text>
<Text style={styles.limitValue}>{maxVideoMinutes ?? '—'}</Text>
</View>
<View style={styles.limitItem}>
<Text style={styles.limitLabel}>{t('storage')}</Text>
<Text style={styles.limitValue}>{storageLimitGb ?? '—'} GB</Text>
</View>
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: COLORS.bg.card,
borderWidth: 1,
borderColor: COLORS.border.accent,
borderRadius: RADIUS.xxl,
padding: LAYOUT.CARD_PADDING,
marginBottom: SPACING.md,
},
sectionTitle: {
color: COLORS.text.primary,
fontSize: FONT_SIZES.lg,
fontWeight: FONT_WEIGHTS.bold,
marginBottom: SPACING.md,
},
planRow: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING.sm,
marginBottom: SPACING.lg,
},
planBadge: {
backgroundColor: COLORS.glass.cyan10,
borderWidth: 1,
borderColor: COLORS.glass.cyan20,
borderRadius: RADIUS.lg,
paddingHorizontal: SPACING.sm + 4,
paddingVertical: SPACING.xs,
},
planBadgeText: {
color: COLORS.brand.accentCyan,
fontSize: FONT_SIZES.xs,
fontWeight: FONT_WEIGHTS.bold,
},
statusDot: {
width: 8,
height: 8,
borderRadius: RADIUS.full,
},
statusText: {
color: COLORS.text.secondary,
fontSize: FONT_SIZES.sm,
},
price: {
color: COLORS.text.hint,
fontSize: FONT_SIZES.sm,
marginLeft: 'auto',
},
creditsContainer: {
marginBottom: SPACING.lg,
},
creditsHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: SPACING.sm,
},
creditsLabel: {
color: COLORS.text.primary,
fontSize: FONT_SIZES.sm,
fontWeight: FONT_WEIGHTS.bold,
},
creditsValue: {
color: COLORS.brand.accentCyan,
fontSize: FONT_SIZES.sm,
fontWeight: FONT_WEIGHTS.bold,
},
progressBg: {
height: 8,
backgroundColor: COLORS.glass.white10,
borderRadius: RADIUS.sm,
overflow: 'hidden',
marginBottom: SPACING.sm,
},
progressFill: {
height: '100%',
borderRadius: RADIUS.sm,
},
creditsDetails: {
flexDirection: 'row',
justifyContent: 'space-between',
},
detailItem: {
alignItems: 'center',
},
detailLabel: {
color: COLORS.text.hint,
fontSize: FONT_SIZES.xs,
},
detailValue: {
color: COLORS.text.primary,
fontSize: FONT_SIZES.sm,
fontWeight: FONT_WEIGHTS.bold,
marginTop: 2,
},
limitsContainer: {
backgroundColor: COLORS.glass.white5,
borderRadius: RADIUS.lg,
padding: SPACING.md,
},
limitsTitle: {
color: COLORS.text.primary,
fontSize: FONT_SIZES.sm,
fontWeight: FONT_WEIGHTS.bold,
marginBottom: SPACING.sm,
},
limitsGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: SPACING.sm,
},
limitItem: {
width: '47%',
flexDirection: 'row',
justifyContent: 'space-between',
},
limitLabel: {
color: COLORS.text.hint,
fontSize: FONT_SIZES.xs,
},
limitValue: {
color: COLORS.text.primary,
fontSize: FONT_SIZES.xs,
fontWeight: FONT_WEIGHTS.bold,
},
});

View file

@ -0,0 +1,24 @@
import { useShareIntentContext } from 'expo-share-intent';
import { resolveShareIntent, type SharedPayload } from '../utils/shareIntent';
/**
* Returns the normalized shared content (URL, plain text, or image)
* received via the OS share sheet, or null when nothing was shared.
*/
export function useSharedContent() {
const { hasShareIntent, shareIntent, resetShareIntent, isReady } =
useShareIntentContext();
let sharedPayload: SharedPayload | null = null;
if (hasShareIntent && shareIntent) {
sharedPayload = resolveShareIntent(shareIntent);
}
return {
isReady,
hasShareIntent,
sharedPayload,
resetShareIntent,
};
}

View file

@ -0,0 +1,41 @@
import { useCallback } from 'react';
import { Alert, Platform } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useAuthStore } from '../store/authStore';
import { useTranslation } from '../i18n';
import type { RootStackParamList } from '../navigation/AppNavigator';
/**
* Shared sign-out flow: confirmation prompt, server-side token revocation +
* local token clearing (clearTokens), then navigation reset to Main.
* Used by DashboardScreen and SettingsScreen.
*/
export function useSignOut() {
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const { clearTokens } = useAuthStore();
const { t } = useTranslation();
return useCallback(() => {
const doSignOut = async () => {
await clearTokens();
navigation.reset({ index: 0, routes: [{ name: 'Main' }] });
};
if (Platform.OS === 'web') {
if (window.confirm(t('signOutConfirm'))) {
doSignOut();
}
return;
}
Alert.alert(t('signOut'), t('signOutConfirm'), [
{ text: t('cancel'), style: 'cancel' },
{
text: t('signOut'),
style: 'destructive',
onPress: doSignOut,
},
]);
}, [clearTokens, navigation, t]);
}

Some files were not shown because too many files have changed in this diff Show more