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]);
|
||||||
|
}
|
||||||