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>
9
.gitignore
vendored
Normal 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
|
|
@ -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
|
|
@ -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 |
|
||||
|------------------------------------|------------|---------|
|
||||
| | | |
|
||||
4
android/didi-app-source/.env.example
Normal 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
|
|
@ -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/
|
||||
30
android/didi-app-source/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
android/didi-app-source/android/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# OSX
|
||||
#
|
||||
.DS_Store
|
||||
|
||||
# Android/IntelliJ
|
||||
#
|
||||
build/
|
||||
.idea
|
||||
.gradle
|
||||
local.properties
|
||||
*.iml
|
||||
*.hprof
|
||||
.cxx/
|
||||
|
||||
# Bundle artifacts
|
||||
*.jsbundle
|
||||
182
android/didi-app-source/android/app/build.gradle
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
android/didi-app-source/android/app/debug.keystore
Normal file
14
android/didi-app-source/android/app/proguard-rules.pro
vendored
Normal 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:
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 8 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 8 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
|
@ -0,0 +1 @@
|
|||
<resources/>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
24
android/didi-app-source/android/build.gradle
Normal 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"
|
||||
67
android/didi-app-source/android/gradle.properties
Normal 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
|
||||
BIN
android/didi-app-source/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
android/didi-app-source/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
|
|
@ -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" "$@"
|
||||
94
android/didi-app-source/android/gradlew.bat
vendored
Normal 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
|
||||
39
android/didi-app-source/android/settings.gradle
Normal 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)
|
||||
49
android/didi-app-source/app.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
android/didi-app-source/assets/adaptive-icon.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
android/didi-app-source/assets/favicon.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
1482
android/didi-app-source/assets/fonts/Merriweather-Bold.ttf
Normal file
1482
android/didi-app-source/assets/fonts/Merriweather-Italic.ttf
Normal file
1482
android/didi-app-source/assets/fonts/Merriweather-Regular.ttf
Normal file
BIN
android/didi-app-source/assets/icon.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
android/didi-app-source/assets/splash-icon.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
9
android/didi-app-source/babel.config.js
Normal 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'],
|
||||
};
|
||||
};
|
||||
8
android/didi-app-source/index.ts
Normal 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
51
android/didi-app-source/package.json
Normal 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
|
||||
}
|
||||
80
android/didi-app-source/scripts/generate-icons.js
Normal 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);
|
||||
BIN
android/didi-app-source/sdsd.jpeg
Normal file
|
After Width: | Height: | Size: 86 KiB |
136
android/didi-app-source/src/api/analysis.ts
Normal 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;
|
||||
}
|
||||
41
android/didi-app-source/src/api/auth.ts
Normal 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;
|
||||
}
|
||||
62
android/didi-app-source/src/api/axios.ts
Normal 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);
|
||||
58
android/didi-app-source/src/api/config.ts
Normal 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,
|
||||
};
|
||||
42
android/didi-app-source/src/api/history.ts
Normal 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)}`
|
||||
);
|
||||
}
|
||||
48
android/didi-app-source/src/api/techniqueDefinitions.ts
Normal 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;
|
||||
}
|
||||
105
android/didi-app-source/src/components/GlassCard.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
309
android/didi-app-source/src/components/HistoryCard.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
286
android/didi-app-source/src/components/TabSelector.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
219
android/didi-app-source/src/components/UploadConsentModal.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
398
android/didi-app-source/src/components/UserProfileWidget.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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)',
|
||||
},
|
||||
});
|
||||
62
android/didi-app-source/src/components/editorial/Chip.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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' }],
|
||||
},
|
||||
});
|
||||
123
android/didi-app-source/src/components/editorial/FindingCard.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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 0–100 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',
|
||||
},
|
||||
});
|
||||
|
|
@ -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)',
|
||||
},
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
280
android/didi-app-source/src/components/sections/AiResults.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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' },
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
384
android/didi-app-source/src/components/sections/Verdict.tsx
Normal 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)',
|
||||
},
|
||||
});
|
||||
6
android/didi-app-source/src/components/sections/index.ts
Normal 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';
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
24
android/didi-app-source/src/hooks/useShareIntent.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
41
android/didi-app-source/src/hooks/useSignOut.ts
Normal 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]);
|
||||
}
|
||||