diff --git a/.easignore b/.easignore new file mode 100644 index 0000000..c74d4f9 --- /dev/null +++ b/.easignore @@ -0,0 +1,20 @@ +# EAS Build ignore +.git +.claude +.codex-tools +.dual-graph +.expo +.gradle-user-home +.superpowers +.vscode +.github +android/.gradle +android/.idea +android/app/build +android/app/build_old_* +android/app/.cxx +android/wear/build +android/wear/.cxx +ios/Pods +*.apk +*.aab diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml new file mode 100644 index 0000000..e4599ac --- /dev/null +++ b/.github/workflows/build-apk.yml @@ -0,0 +1,156 @@ +name: Build APK & Release + +on: + workflow_dispatch: + inputs: + release_tag: + description: 'Release tag (e.g. v1.2.0)' + required: false + default: '' + push: + branches: + - main + paths: + - 'app.json' + - 'src/**' + - 'App.tsx' + - 'package.json' + - 'android/**' + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + + outputs: + version: ${{ steps.meta.outputs.version }} + tag: ${{ steps.meta.outputs.tag }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ hashFiles('android/**/*.gradle*', 'android/gradle.properties') }} + restore-keys: gradle- + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Install SDK components + run: | + echo "y" | sdkmanager \ + "ndk;27.1.12297006" \ + "build-tools;36.0.0" \ + "platforms;android-36" \ + "cmake;3.22.1" + + - name: Install dependencies + run: npm install --legacy-peer-deps + + - name: Fix expo-modules-core Gradle bugs + run: | + FILE="node_modules/expo-modules-core/android/build.gradle" + sed -i "s|apply plugin: 'com.android.library'|// Fix: project-level re-declaration\ndef _coreFeatures = project.findProperty(\"coreFeatures\") ?: []\next.shouldIncludeCompose = _coreFeatures.contains(\"compose\")\n\napply plugin: 'com.android.library'|" "$FILE" + sed -i 's/^\s*compose shouldIncludeCompose\s*$/ compose = shouldIncludeCompose/' "$FILE" + + - name: Read version & compute tag + id: meta + run: | + VERSION=$(node -p "require('./app.json').expo.version") + TAG="${{ github.event.inputs.release_tag }}" + if [ -z "$TAG" ]; then + TAG="v${VERSION}" + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "Building AeroStaff Pro $VERSION (tag: $TAG)" + + - name: Make gradlew executable + run: chmod +x android/gradlew + + - name: Restore signing keystore from repository secret + run: | + mkdir -p android/app "$HOME/.android" + echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/app/debug.keystore + cp android/app/debug.keystore "$HOME/.android/debug.keystore" + + - name: Build release APK (standalone, JS bundle embedded) + run: | + cd android + ./gradlew :app:assembleRelease \ + --no-daemon \ + -Pandroid.overridePathCheck=true + env: + ANDROID_HOME: ${{ env.ANDROID_SDK_ROOT }} + GRADLE_OPTS: "-Xmx4g -XX:MaxMetaspaceSize=512m" + + - name: Find & rename APK + id: apk + run: | + APK=$(find android/app/build/outputs/apk/release -name "*.apk" -type f | head -1) + echo "Found: $APK" + VERSION=${{ steps.meta.outputs.version }} + DEST="AeroStaffPro-v${VERSION}.apk" + mkdir -p artifacts + cp "$APK" "artifacts/$DEST" + echo "apk_path=artifacts/$DEST" >> "$GITHUB_OUTPUT" + echo "apk_name=$DEST" >> "$GITHUB_OUTPUT" + ls -lh artifacts/ + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: AeroStaffPro-v${{ steps.meta.outputs.version }} + path: ${{ steps.apk.outputs.apk_path }} + + release: + needs: build + runs-on: ubuntu-latest + + steps: + - name: Download APK artifact + uses: actions/download-artifact@v4 + with: + name: AeroStaffPro-v${{ needs.build.outputs.version }} + path: artifacts/ + + - name: Create or update GitHub release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.build.outputs.tag }} + target_commitish: ${{ github.sha }} + name: "AeroStaff Pro ${{ needs.build.outputs.version }}" + body: | + ## AeroStaff Pro ${{ needs.build.outputs.version }} + + APK Android generato automaticamente da GitHub Actions. + + ### Contenuto + - Bundle JavaScript incorporato + - Build release firmata con la keystore del repository + - APK pronto da installare + + ### Download + Installa `AeroStaffPro-v${{ needs.build.outputs.version }}.apk` sul tuo dispositivo Android. + prerelease: false + files: artifacts/*.apk diff --git a/.gitignore b/.gitignore index a198171..23a2510 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ # dependencies node_modules/ +# Old build artifacts +android/app/build_old_*/ + # Expo .expo/ dist/ @@ -33,6 +36,7 @@ yarn-error.* # local env files .env*.local +android/keystore.properties # typescript *.tsbuildinfo diff --git a/App.tsx b/App.tsx index 4798824..a81a3bd 100644 --- a/App.tsx +++ b/App.tsx @@ -5,6 +5,7 @@ import { BlurView } from 'expo-blur'; import * as Haptics from 'expo-haptics'; import { MaterialIcons } from '@expo/vector-icons'; import { ThemeProvider, useAppTheme } from './src/context/ThemeContext'; +import { LanguageProvider, useLanguage } from './src/context/LanguageContext'; import { AirportProvider } from './src/context/AirportContext'; import HomeScreen from './src/screens/HomeScreen'; import TraveldocScreen from './src/screens/TraveldocScreen'; @@ -77,17 +78,26 @@ function GlassTab({ icon, label, focused, activeColor, inactiveColor, onPress }: // ─── Inner app (inside ThemeProvider) ──────────────────────────────────────── function AppInner() { const { colors, mode } = useAppTheme(); + const { t } = useLanguage(); const [activeTab, setActiveTab] = useState('Shifts'); const [drawerOpen, setDrawerOpen] = useState(false); const [overlay, setOverlay] = useState(null); + const tabLabels: Record = { + Shifts: t('tabHome'), Calendar: t('tabShifts'), Flights: t('tabFlights'), TravelDoc: t('tabTravelDoc'), + }; + const overlayTitles: Record, string> = { + Notepad: t('overlayNotepad'), Phonebook: t('overlayPhonebook'), + Passwords: t('overlayPasswords'), Manuals: t('overlayManuals'), Settings: t('overlaySettings'), + }; + const handleDrawerSelect = (id: string) => setOverlay(id as OverlayScreen); const handleBack = () => setOverlay(null); // ─── Auto-schedule flight notifications on startup ───────────────────────── useEffect(() => { autoScheduleNotifications().then(count => { - if (count > 0) console.log(`Auto-scheduled ${count} notifications`); + if (count > 0 && __DEV__) console.log(`Auto-scheduled ${count} notifications`); }).catch(() => {}); }, []); @@ -165,7 +175,7 @@ function AppInner() { }; - const appBarTitle = overlay ? OVERLAY_TITLES[overlay] : 'AeroStaff Pro'; + const appBarTitle = overlay ? overlayTitles[overlay] : 'AeroStaff Pro'; const isWeather = mode === 'weather' && !!colors.gradient; return ( @@ -175,8 +185,13 @@ function AppInner() { backgroundColor={colors.appBar} /> - {/* Top App Bar */} - + {/* Top App Bar — liquid glass */} + + {overlay ? ( @@ -187,15 +202,20 @@ function AppInner() { )} - {appBarTitle} + {appBarTitle} {isWeather && ( {colors.weatherIcon} {colors.weatherLabel} )} - - MR - - + + MR + + {/* Screen Content */} {isWeather ? ( @@ -235,10 +255,10 @@ function AppInner() { { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); goToTab(TABS.findIndex(t => t.id === tab.id)); @@ -265,7 +285,9 @@ export default function App() { return ( - + + + ); @@ -282,10 +304,11 @@ const styles = StyleSheet.create({ paddingHorizontal: 12, paddingVertical: 10, borderBottomWidth: 1, + overflow: 'hidden', }, iconBtn: { padding: 6, borderRadius: 8, marginRight: 6 }, titleRow: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: 8 }, - appBarTitle: { fontSize: 18, fontWeight: 'bold', letterSpacing: 0.3 }, + appBarTitle: { fontSize: 18, fontWeight: '700', letterSpacing: 0.3 }, weatherChip: { fontSize: 11, color: 'rgba(255,255,255,0.8)', backgroundColor: 'rgba(255,255,255,0.15)', @@ -295,8 +318,9 @@ const styles = StyleSheet.create({ avatar: { width: 34, height: 34, borderRadius: 17, justifyContent: 'center', alignItems: 'center', + overflow: 'hidden', }, - avatarText: { fontSize: 12, fontWeight: 'bold' }, + avatarText: { fontSize: 12, fontWeight: '700', color: '#FFFFFF' }, content: { flex: 1 }, // ─── Glassmorphic floating tab bar ─── tabBarWrapper: { @@ -307,19 +331,19 @@ const styles = StyleSheet.create({ }, tabBarBlur: { flexDirection: 'row', - height: 64, - borderRadius: 32, + height: 66, + borderRadius: 33, justifyContent: 'space-around', alignItems: 'center', overflow: 'hidden', - borderWidth: 1, - borderColor: 'rgba(255,255,255,0.15)', + borderWidth: 0.75, + borderColor: 'rgba(0,0,0,0.08)', }, glassTab: { alignItems: 'center', justifyContent: 'center', - width: 64, - height: 54, + width: 74, + height: 56, }, glassLabel: { fontSize: 10, @@ -329,9 +353,14 @@ const styles = StyleSheet.create({ }, glassIndicator: { position: 'absolute', - bottom: 0, - width: 20, - height: 3, + bottom: 4, + width: 22, + height: 3.5, borderRadius: 999, + shadowColor: '#F47B16', + shadowOpacity: 0.5, + shadowRadius: 6, + shadowOffset: { width: 0, height: 0 }, + elevation: 4, }, }); diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 3ed1329..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,94 +0,0 @@ - -# Dual-Graph Context Policy - -This project uses a local dual-graph MCP server for efficient context retrieval. - -## MANDATORY: Adaptive graph_continue rule - -**Call `graph_continue` ONLY when you do NOT already know the relevant files.** - -### Call `graph_continue` when: -- This is the first message of a new task / conversation -- The task shifts to a completely different area of the codebase -- You need files you haven't read yet in this session - -### SKIP `graph_continue` when: -- You already identified the relevant files earlier in this conversation -- You are doing follow-up work on files already read (verify, refactor, test, docs, cleanup, commit) -- The task is pure text (writing a commit message, summarising, explaining) - -**If skipping, go directly to `graph_read` on the already-known `file::symbol`.** - -## When you DO call graph_continue - -1. **If `graph_continue` returns `needs_project=true`**: call `graph_scan` with `pwd`. Do NOT ask the user. - -2. **If `graph_continue` returns `skip=true`**: fewer than 5 files — read only specifically named files. - -3. **Read `recommended_files`** using `graph_read`. - - Always use `file::symbol` notation (e.g. `src/auth.ts::handleLogin`) — never read whole files. - - `recommended_files` entries that already contain `::` must be passed verbatim. - -4. **Obey confidence caps:** - - `confidence=high` -> Stop. Do NOT grep or explore further. - - `confidence=medium` -> `fallback_rg` at most `max_supplementary_greps` times, then `graph_read` at most `max_supplementary_files` more symbols. Stop. - - `confidence=low` -> same as medium. Stop. - -## Session State (compact, update after every turn) - -Maintain a short JSON block in your working memory. Update it after each turn: - -```json -{ - "files_identified": ["path/to/file.py"], - "symbols_changed": ["module::function"], - "fix_applied": true, - "features_added": ["description"], - "open_issues": ["one-line note"] -} -``` - -Use this state — not prose summaries — to remember what's been done across turns. - -## Token Usage - -A `token-counter` MCP is available for tracking live token usage. - -- Before reading a large file: `count_tokens({text: ""})` to check cost first. -- To show running session cost: `get_session_stats()` -- To log completed task: `log_usage({input_tokens: N, output_tokens: N, description: "task"})` - -## Rules - -- Do NOT use `rg`, `grep`, or bash file exploration before calling `graph_continue` (when required). -- Do NOT do broad/recursive exploration at any confidence level. -- `max_supplementary_greps` and `max_supplementary_files` are hard caps — never exceed them. -- Do NOT call `graph_continue` more than once per turn. -- Always use `file::symbol` notation with `graph_read` — never bare filenames. -- After edits, call `graph_register_edit` with changed files using `file::symbol` notation. - -## Context Store - -Whenever you make a decision, identify a task, note a next step, fact, or blocker during a conversation, append it to `.dual-graph/context-store.json`. - -**Entry format:** -```json -{"type": "decision|task|next|fact|blocker", "content": "one sentence max 15 words", "tags": ["topic"], "files": ["relevant/file.ts"], "date": "YYYY-MM-DD"} -``` - -**To append:** Read the file -> add the new entry to the array -> Write it back -> call `graph_register_edit` on `.dual-graph/context-store.json`. - -**Rules:** -- Only log things worth remembering across sessions (not every minor detail) -- `content` must be under 15 words -- `files` lists the files this decision/task relates to (can be empty) -- Log immediately when the item arises — not at session end - -## Session End - -When the user signals they are done (e.g. "bye", "done", "wrap up", "end session"), proactively update `CONTEXT.md` in the project root with: -- **Current Task**: one sentence on what was being worked on -- **Key Decisions**: bullet list, max 3 items -- **Next Steps**: bullet list, max 3 items - -Keep `CONTEXT.md` under 20 lines total. Do NOT summarize the full conversation — only what's needed to resume next session. diff --git a/DESIGN.md b/DESIGN.md deleted file mode 100644 index 68aaf2d..0000000 --- a/DESIGN.md +++ /dev/null @@ -1,50 +0,0 @@ -# Design System: Pisa Flight Schedule - -Questa documentazione riassume le specifiche di design estratte dal workspace Stitch (`projects/494829064577014119`), studiate per garantire la coerenza visiva dell'applicazione. - -## 🎨 Palette Colori - -| Elemento | Valore | Descrizione | -| :--- | :--- | :--- | -| **Color Mode** | `DARK` | Interfaccia scura di base per ridurre l’affaticamento visivo. | -| **Colore Primario** | `#136DEC` | Blu elettrico per pulsanti, evidenziati e call-to-action. | -| **Saturazione** | `2` | Livello di saturazione medio-alto per mantenere colori vibranti. | - ---- - -## 🅰️ Tipografia - -- **Font Family:** `Inter` (Sans-Serif) - - *Utilizzo:* Elevata leggibilità su schermi mobile, adatta per numeri di volo e schemi dati compatti. - - *Fallback:* `System`, `San Francisco` (iOS), `Roboto` (Android). - ---- - -## 📐 Struttura e Bordi - -- **Roundness (Raggi di Arrotondamento):** `ROUND_EIGHT` (~8px) - - applicato a: - - Card dei Voli / Schede Shift - - Pulsanti e Barre di Navigazione - - Modali e Pop-up - ---- - -## 💡 Integrazione nel Canale Mobile (React Native) - -Per applicare questi valori nel foglio di stile locale: - -```typescript -export const StitchTheme = { - dark: true, - colors: { - primary: '#136DEC', - background: '#121212', // Standard dark background - card: '#1E1E1E', // Card background fallback - text: '#FFFFFF', - border: '#136DEC44', // Tinted Borders - }, - roundness: 8, - fontFamily: 'Inter', -}; -``` diff --git a/README.md b/README.md index f820718..95ccfcd 100644 --- a/README.md +++ b/README.md @@ -85,19 +85,31 @@ npm run typecheck ## Build and Releases -APK files are also published in GitHub Releases when available. +APK files are published in [GitHub Releases](https://github.com/TargetMisser/FlightWorkApp/releases). -Expected assets: +Latest stable: **v2.6.1** -- `FlightWorkApp-v1.1.0-release.apk`: main Android app -- `FlightWorkApp-Wear-v1.1.0.apk`: Wear OS companion +To install: -To install a release: +1. Open the Releases section and download `AeroStaffPro-v2.6.1.apk`. +2. Transfer to your Android device and install (enable "Unknown sources" if needed). +3. For Wear OS, pair the phone app — the watch companion installs automatically. -1. Open the repository Releases section. -2. Download the APK you need. -3. Install the APK on your Android device. -4. For Wear OS, use the watch-specific APK. +To build locally: + +```bash +cd android +.\gradlew.bat assembleRelease +# Output: android/app/build/outputs/apk/release/app-release.apk +``` + +Release builds require signing credentials from one of these sources: + +- `android/keystore.properties` +- `~/.flightwork/keystore.properties` +- `FLIGHTWORKAPP_RELEASE_*` environment variables + +You can start from `android/keystore.properties.example` and point it to your signing keystore. ## Branch Structure @@ -122,15 +134,6 @@ git commit -m "Describe your change" git push ``` -## GitHub Actions - -The repository includes workflows for: - -- basic CI -- snapshot releases on `main` - -Note: execution depends on GitHub Actions being available for the account/repository. - ## Notes - The repository is set up to be used from multiple computers. diff --git a/android/app/build.gradle b/android/app/build.gradle index 0b49f04..1526241 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,8 +1,27 @@ +import java.util.Properties + apply plugin: "com.android.application" apply plugin: "org.jetbrains.kotlin.android" apply plugin: "com.facebook.react" def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath() +def keystoreProperties = new Properties() +def localKeystorePropertiesFile = rootProject.file("keystore.properties") +def sharedKeystorePropertiesFile = new File(System.getProperty("user.home"), ".flightwork/keystore.properties") +def keystorePropertiesFile = localKeystorePropertiesFile.exists() ? localKeystorePropertiesFile : sharedKeystorePropertiesFile + +if (keystorePropertiesFile.exists()) { + keystorePropertiesFile.withInputStream { stream -> + keystoreProperties.load(stream) + } +} + +def releaseStoreFilePath = keystoreProperties.getProperty("storeFile") ?: System.getenv("FLIGHTWORKAPP_RELEASE_STORE_FILE") +def releaseStorePassword = keystoreProperties.getProperty("storePassword") ?: System.getenv("FLIGHTWORKAPP_RELEASE_STORE_PASSWORD") +def releaseKeyAlias = keystoreProperties.getProperty("keyAlias") ?: System.getenv("FLIGHTWORKAPP_RELEASE_KEY_ALIAS") +def releaseKeyPassword = keystoreProperties.getProperty("keyPassword") ?: System.getenv("FLIGHTWORKAPP_RELEASE_KEY_PASSWORD") +def normalizedReleaseStoreFilePath = releaseStoreFilePath?.replace("\\", "/") +def hasReleaseSigningConfig = [normalizedReleaseStoreFilePath, releaseStorePassword, releaseKeyAlias, releaseKeyPassword].every { it } /** * This is the configuration block to customize your React Native Android app. @@ -92,8 +111,8 @@ android { applicationId 'com.anonymous.FlightWorkApp' minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 1 - versionName "1.1.0" + versionCode 14 + versionName "2.6.4" buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" } @@ -104,15 +123,24 @@ android { keyAlias 'androiddebugkey' keyPassword 'android' } + if (hasReleaseSigningConfig) { + release { + storeFile file(normalizedReleaseStoreFilePath) + storePassword releaseStorePassword + keyAlias releaseKeyAlias + keyPassword releaseKeyPassword + enableV1Signing true + enableV2Signing true + enableV3Signing true + } + } } 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 + signingConfig hasReleaseSigningConfig ? signingConfigs.release : signingConfigs.debug def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false' shrinkResources enableShrinkResources.toBoolean() minifyEnabled enableMinifyInReleaseBuilds diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index 012ebef..d08e62e 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -1,7 +1,7 @@ - #ffffff - #E6F4FE - #023c69 - #ffffff - #2563EB - \ No newline at end of file + #1A0A00 + #3A1800 + #F47B16 + #1A0A00 + #F47B16 + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 686e992..c82fb3c 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,6 +1,6 @@ - FlightWorkApp + AeroStaff Pro contain false Voli del turno corrente con orari CI e Gate - \ No newline at end of file + diff --git a/android/keystore.properties.example b/android/keystore.properties.example new file mode 100644 index 0000000..90ce58c --- /dev/null +++ b/android/keystore.properties.example @@ -0,0 +1,4 @@ +storeFile=C:/path/to/flightworkapp-release.jks +storePassword=replace-with-your-store-password +keyAlias=flightworkapp +keyPassword=replace-with-your-key-password diff --git a/android/wear/build.gradle b/android/wear/build.gradle index 14bdf39..8e1cf4f 100644 --- a/android/wear/build.gradle +++ b/android/wear/build.gradle @@ -1,7 +1,27 @@ +import java.util.Properties + apply plugin: 'com.android.application' apply plugin: 'org.jetbrains.kotlin.android' apply plugin: 'org.jetbrains.kotlin.plugin.compose' +def keystoreProperties = new Properties() +def localKeystorePropertiesFile = rootProject.file('keystore.properties') +def sharedKeystorePropertiesFile = new File(System.getProperty('user.home'), '.flightwork/keystore.properties') +def keystorePropertiesFile = localKeystorePropertiesFile.exists() ? localKeystorePropertiesFile : sharedKeystorePropertiesFile + +if (keystorePropertiesFile.exists()) { + keystorePropertiesFile.withInputStream { stream -> + keystoreProperties.load(stream) + } +} + +def releaseStoreFilePath = keystoreProperties.getProperty('storeFile') ?: System.getenv('FLIGHTWORKAPP_RELEASE_STORE_FILE') +def releaseStorePassword = keystoreProperties.getProperty('storePassword') ?: System.getenv('FLIGHTWORKAPP_RELEASE_STORE_PASSWORD') +def releaseKeyAlias = keystoreProperties.getProperty('keyAlias') ?: System.getenv('FLIGHTWORKAPP_RELEASE_KEY_ALIAS') +def releaseKeyPassword = keystoreProperties.getProperty('keyPassword') ?: System.getenv('FLIGHTWORKAPP_RELEASE_KEY_PASSWORD') +def normalizedReleaseStoreFilePath = releaseStoreFilePath?.replace('\\', '/') +def hasReleaseSigningConfig = [normalizedReleaseStoreFilePath, releaseStorePassword, releaseKeyAlias, releaseKeyPassword].every { it } + android { namespace 'com.anonymous.flightworkapp.wear' compileSdk 36 @@ -10,8 +30,8 @@ android { applicationId 'com.anonymous.FlightWorkApp' minSdkVersion 30 targetSdkVersion 36 - versionCode 1 - versionName "1.0" + versionCode 7 + versionName "2.6.1" } signingConfigs { @@ -21,11 +41,22 @@ android { keyAlias 'androiddebugkey' keyPassword 'android' } + if (hasReleaseSigningConfig) { + release { + storeFile file(normalizedReleaseStoreFilePath) + storePassword releaseStorePassword + keyAlias releaseKeyAlias + keyPassword releaseKeyPassword + enableV1Signing true + enableV2Signing true + enableV3Signing true + } + } } buildTypes { release { - signingConfig signingConfigs.debug + signingConfig hasReleaseSigningConfig ? signingConfigs.release : signingConfigs.debug } } diff --git a/android/wear/src/main/res/values/strings.xml b/android/wear/src/main/res/values/strings.xml index 78519b4..1ec4ea7 100644 --- a/android/wear/src/main/res/values/strings.xml +++ b/android/wear/src/main/res/values/strings.xml @@ -1,4 +1,4 @@ - AeroStaff + AeroStaff Pro diff --git a/app.json b/app.json index 0473df0..05f2f21 100644 --- a/app.json +++ b/app.json @@ -1,8 +1,8 @@ { "expo": { - "name": "FlightWorkApp", + "name": "AeroStaff Pro", "slug": "FlightWorkApp", - "version": "1.1.0", + "version": "2.6.4", "orientation": "portrait", "icon": "./assets/icon.png", "userInterfaceStyle": "light", @@ -16,7 +16,7 @@ }, "android": { "adaptiveIcon": { - "backgroundColor": "#E6F4FE", + "backgroundColor": "#FFF3E0", "foregroundImage": "./assets/android-icon-foreground.png", "backgroundImage": "./assets/android-icon-background.png", "monochromeImage": "./assets/android-icon-monochrome.png" @@ -32,7 +32,7 @@ "expo-notifications", { "icon": "./assets/icon.png", - "color": "#2563EB", + "color": "#F47B16", "defaultChannel": "voli" } ], diff --git a/build.bat b/build.bat deleted file mode 100644 index 86ee416..0000000 --- a/build.bat +++ /dev/null @@ -1,28 +0,0 @@ -@echo off -echo [1/4] Killing Java and Node processes... -taskkill /f /im java.exe >nul 2>&1 -taskkill /f /im node.exe >nul 2>&1 -timeout /t 2 /nobreak >nul - -echo [2/4] Cleaning old builds... -node rename_node_modules_builds.js -cd android -if exist app\build ( - ren app\build build_old_%RANDOM% >nul 2>&1 -) - -echo [3/4] Building release APK... -call gradlew.bat assembleRelease -if %ERRORLEVEL% neq 0 ( - echo BUILD FAILED! - pause - exit /b 1 -) - -echo [4/4] Copying APK to Downloads... -copy /y app\build\outputs\apk\release\app-release.apk "%USERPROFILE%\Downloads\AeroStaffPro.apk" >nul -echo. -echo ======================================== -echo APK pronta: %USERPROFILE%\Downloads\AeroStaffPro.apk -echo ======================================== -pause diff --git a/build_apk.bat b/build_apk.bat deleted file mode 100644 index 3765c94..0000000 --- a/build_apk.bat +++ /dev/null @@ -1,2 +0,0 @@ -cd android -call gradlew.bat assembleDebug > ..\build_output.txt 2>&1 diff --git a/build_apk_force.bat b/build_apk_force.bat deleted file mode 100644 index 06e9e79..0000000 --- a/build_apk_force.bat +++ /dev/null @@ -1,5 +0,0 @@ -taskkill /f /im java.exe >nul 2>&1 -taskkill /f /im node.exe >nul 2>&1 -cd android -call gradlew.bat clean > ..\build_output_clean.txt 2>&1 -call gradlew.bat assembleDebug > ..\build_output.txt 2>&1 diff --git a/build_apk_release.bat b/build_apk_release.bat deleted file mode 100644 index cdaa5fd..0000000 --- a/build_apk_release.bat +++ /dev/null @@ -1,6 +0,0 @@ -taskkill /f /im java.exe >nul 2>&1 -taskkill /f /im node.exe >nul 2>&1 -node rename_node_modules_builds.js -cd android -ren app\build build_old_%RANDOM% >nul 2>&1 -call gradlew.bat assembleRelease > ..\build_output_release.txt 2>&1 diff --git a/build_apk_rename.bat b/build_apk_rename.bat deleted file mode 100644 index 19c3b3f..0000000 --- a/build_apk_rename.bat +++ /dev/null @@ -1,7 +0,0 @@ -taskkill /f /im java.exe >nul 2>&1 -taskkill /f /im node.exe >nul 2>&1 -node rename_node_modules_builds.js -cd android -ren app\build build_old_%RANDOM% >nul 2>&1 -call gradlew.bat assembleDebug > ..\build_output.txt 2>&1 - diff --git a/build_apk_safe.bat b/build_apk_safe.bat deleted file mode 100644 index 5eb20a8..0000000 --- a/build_apk_safe.bat +++ /dev/null @@ -1,4 +0,0 @@ -cd android -call gradlew.bat --stop > ..\build_output_stop.txt 2>&1 -call gradlew.bat clean > ..\build_output_clean.txt 2>&1 -call gradlew.bat assembleDebug > ..\build_output.txt 2>&1 diff --git a/dir_sizes.js b/dir_sizes.js deleted file mode 100644 index e5c8826..0000000 --- a/dir_sizes.js +++ /dev/null @@ -1,40 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -function getDirSize(dirPath) { - let size = 0; - try { - const files = fs.readdirSync(dirPath); - for (const file of files) { - const filePath = path.join(dirPath, file); - const stats = fs.statSync(filePath); - if (stats.isDirectory()) { - size += getDirSize(filePath); - } else { - size += stats.size; - } - } - } catch (err) { - // Ignore errors for system files or permission issues - } - return size; -} - -const root = process.cwd(); -const items = fs.readdirSync(root); - -console.log('Directory Sizes:'); -for (const item of items) { - const itemPath = path.join(root, item); - const stats = fs.statSync(itemPath); - if (stats.isDirectory()) { - const size = getDirSize(itemPath); - const sizeGB = (size / (1024 * 1024 * 1024)).toFixed(2); - console.log(`${item}: ${sizeGB} GB`); - } else { - const sizeGB = (stats.size / (1024 * 1024 * 1024)).toFixed(2); - if (parseFloat(sizeGB) > 0.01) { - console.log(`${item}: ${sizeGB} GB`); - } - } -} diff --git a/docs/superpowers/plans/2026-03-25-flight-card-rework.md b/docs/superpowers/plans/2026-03-25-flight-card-rework.md deleted file mode 100644 index bb26fdf..0000000 --- a/docs/superpowers/plans/2026-03-25-flight-card-rework.md +++ /dev/null @@ -1,187 +0,0 @@ -# Flight Card Rework Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Rework FlightScreen flight cards to B2 layout (colored airline header + compact body), and set Departures as default tab. - -**Architecture:** Single file edit (`src/screens/FlightScreen.tsx`). Replace `AirlineLogo` component with inline `LogoPill`, rewrite `renderFlight` with two-section card, swap old card styles for new ones. No new files. - -**Tech Stack:** React Native, TypeScript, `StyleSheet.create`, `Image`, existing `getAirlineColor`/`getAirlineOps` helpers. - ---- - -### Task 1: Set Departures as default tab - -**Files:** -- Modify: `src/screens/FlightScreen.tsx:153` - -- [ ] **Step 1: Make the change** - -In `src/screens/FlightScreen.tsx` at line 153, change: -```typescript -const [activeTab, setActiveTab] = useState<'arrivals' | 'departures'>('arrivals'); -``` -to: -```typescript -const [activeTab, setActiveTab] = useState<'arrivals' | 'departures'>('departures'); -``` - -- [ ] **Step 2: Verify** - -Open the app in Expo Go. Navigate to the Voli screen. Confirm the "🛫 Partenze" tab is selected by default when the screen loads. - -- [ ] **Step 3: Commit** - -```bash -git add src/screens/FlightScreen.tsx -git commit -m "feat: set Departures as default tab in FlightScreen" -``` - ---- - -### Task 2: Replace AirlineLogo with LogoPill + add new styles to makeStyles - -**Files:** -- Modify: `src/screens/FlightScreen.tsx:61-83` (replace component + logoStyles) -- Modify: `src/screens/FlightScreen.tsx:392-434` (add new styles to makeStyles) - -- [ ] **Step 1: Replace AirlineLogo component and logoStyles** - -Remove lines 61–83 entirely (the `function AirlineLogo` block and `const logoStyles = StyleSheet.create(...)`) and replace with: - -```typescript -function LogoPill({ iataCode, airlineName, color }: { iataCode: string; airlineName: string; color: string }) { - const [err, setErr] = useState(false); - const uri = `https://pics.avs.io/160/60/${(iataCode || '').toUpperCase()}.png`; - const initials = airlineName.split(' ').slice(0, 2).map(w => w[0]).join('').toUpperCase(); - if (iataCode && !err) { - return ( - - setErr(true)} /> - - ); - } - return ( - - {initials} - - ); -} -``` - -- [ ] **Step 2: Add new card styles to makeStyles** - -Inside the `makeStyles` function (at the end of the `StyleSheet.create({...})` object, before the closing `})`), add these new entries after the existing `opsSub` line: - -```typescript - cardHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 10, paddingHorizontal: 14 }, - headerLeft: { flexDirection: 'row', alignItems: 'center', gap: 10 }, - headerFlightNum: { color: '#fff', fontWeight: '900', fontSize: 15, lineHeight: 18 }, - headerAirlineName: { color: 'rgba(255,255,255,0.8)', fontSize: 10 }, - headerTime: { color: '#fff', fontWeight: '900', fontSize: 18, lineHeight: 20, textAlign: 'right' }, - headerDest: { color: 'rgba(255,255,255,0.8)', fontSize: 10, textAlign: 'right' }, - cardBody: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8, paddingHorizontal: 14, backgroundColor: c.card }, - bodyInfo: { flex: 1, fontSize: 9, color: c.textSub }, -``` - -- [ ] **Step 3: Commit** - -```bash -git add src/screens/FlightScreen.tsx -git commit -m "feat: add LogoPill component and new card styles" -``` - ---- - -### Task 3: Rewrite renderFlight with B2 layout + remove old styles - -**Files:** -- Modify: `src/screens/FlightScreen.tsx:260-325` (replace renderFlight body) -- Modify: `src/screens/FlightScreen.tsx:411-432` (remove old card styles from makeStyles) - -- [ ] **Step 1: Replace renderFlight body** - -Replace lines 279–324 (the `return (` block inside `renderFlight`) with the following. Keep the variable declarations above (`flightNumber`, `airline`, `iataCode`, `statusText`, `raw`, `statusColor`, `originDest`, `ts`, `time`, `duringShift`, `color`, `ops`, `fmt`) unchanged. - -```typescript - return ( - - {duringShift && ⭐ DURANTE IL TUO TURNO} - {/* Header */} - - - - - {flightNumber} - {airline} - - - - {time} - {originDest} - - - {/* Body */} - - {activeTab === 'departures' && ops ? ( - - {`🖥 CI ${fmt(ops.checkInOpen)}–${fmt(ops.checkInClose)} · 🚪 Gate ${fmt(ops.gateOpen)}–${fmt(ops.gateClose)}`} - - ) : ( - {`Da: ${originDest}`} - )} - - {statusText} - - - - ); -``` - -- [ ] **Step 2: Remove old card styles from makeStyles** - -In the `makeStyles` function, remove these entries entirely: - -```typescript - cardRow: { flexDirection: 'row', alignItems: 'center', padding: 14 }, - airlineName: { fontSize: 12, color: c.textSub, marginBottom: 1 }, - flightNum: { fontSize: 17, fontWeight: 'bold', color: c.primaryDark }, - route: { fontSize: 12, color: c.textMuted, marginTop: 2 }, - routeDest: { color: c.text, fontWeight: '600' }, - time: { fontSize: 20, fontWeight: 'bold', color: c.primary }, - opsRow: { - flexDirection: 'row', - alignItems: 'center', - borderTopWidth: 1, - borderTopColor: c.border, - paddingVertical: 8, - paddingHorizontal: 14, - backgroundColor: c.cardSecondary, - }, - opsCell: { flex: 1, alignItems: 'center', gap: 2 }, - opsDivider: { width: 1, height: 28, backgroundColor: c.border }, - opsLabel: { fontSize: 9, fontWeight: '600', color: c.textMuted, textTransform: 'uppercase', letterSpacing: 0.4 }, - opsTime: { fontSize: 13, fontWeight: '700' }, - opsSub: { fontSize: 9, color: c.textMuted }, -``` - -Keep these existing entries untouched: -- `card`, `cardShift`, `shiftBanner`, `shiftBannerText`, `statusPill`, `statusText` -- All page/nav styles (`pageHeader`, `notifBtn`, `notifBtnActive`, `notifBadge`, `notifBadgeTxt`, `pageTitle`, `pageSub`, `controlsRow`, `segment`, `segBtn`, `segBtnActive`, `segBtnText`, `segBtnTextActive`) - -- [ ] **Step 3: Verify in Expo** - -Open app → Voli screen. Check: -- [ ] Departures tab active by default -- [ ] Each departure card shows a colored header (airline brand color) with white logo pill, flight number, airline name, time, and city -- [ ] Body row shows `🖥 CI HH:MM–HH:MM · 🚪 Gate HH:MM–HH:MM` on the left and status pill on the right -- [ ] Arrivals tab: each card shows colored header (same layout), body shows `Da: [city]` + status pill -- [ ] Shift banner (amber) renders above the header for shift flights -- [ ] Dark mode / weather mode — header stays colored, body uses `c.card` background - -- [ ] **Step 4: Commit** - -```bash -git add src/screens/FlightScreen.tsx -git commit -m "feat: rework flight cards to B2 layout with colored airline header" -``` diff --git a/docs/superpowers/plans/2026-03-25-flight-ops-times.md b/docs/superpowers/plans/2026-03-25-flight-ops-times.md deleted file mode 100644 index bdce0c7..0000000 --- a/docs/superpowers/plans/2026-03-25-flight-ops-times.md +++ /dev/null @@ -1,177 +0,0 @@ -# Flight Operational Times Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a compact 4-column row to each departure flight card showing check-in open/close and gate open/close times, calculated from the scheduled departure time using a per-airline policy table. - -**Architecture:** Single file change to `src/screens/FlightScreen.tsx`. Add a module-level `AIRLINE_OPS` lookup table and `getAirlineOps()` helper, then render an ops times row inside `renderFlight` when `activeTab === 'departures'` and a departure timestamp exists. - -**Tech Stack:** React Native, existing FlightScreen patterns (`makeStyles`, `useAppTheme`, `useCallback`) - ---- - -## File Map - -| Action | File | What changes | -|--------|------|-------------| -| Modify | `src/screens/FlightScreen.tsx` | Add `AIRLINE_OPS` + `getAirlineOps()`, ops row in `renderFlight`, new styles in `makeStyles` | - ---- - -### Task 1: Add AIRLINE_OPS table and getAirlineOps() helper - -**Files:** -- Modify: `src/screens/FlightScreen.tsx` - -- [ ] **Step 1: Add AirlineOps type and AIRLINE_OPS constant** - -In `src/screens/FlightScreen.tsx`, find the existing `airlineColors` block (around line 31): - -```typescript -const airlineColors: Record = { - 'wizz': '#C6006E', 'easyjet': '#FF6600', ... -}; -function getAirlineColor(name: string) { ... } -``` - -Insert the following **after** `getAirlineColor` (after line ~38): - -```typescript -type AirlineOps = { checkInOpen: number; checkInClose: number; gateOpen: number; gateClose: number }; - -const DEFAULT_OPS: AirlineOps = { checkInOpen: 120, checkInClose: 40, gateOpen: 30, gateClose: 20 }; - -const AIRLINE_OPS: Array<{ key: string; ops: AirlineOps }> = [ - { key: 'easyjet', ops: { checkInOpen: 120, checkInClose: 40, gateOpen: 30, gateClose: 20 } }, - { key: 'wizz', ops: { checkInOpen: 180, checkInClose: 40, gateOpen: 30, gateClose: 15 } }, - { key: 'ryanair', ops: { checkInOpen: 150, checkInClose: 40, gateOpen: 30, gateClose: 20 } }, - { key: 'aer lingus', ops: { checkInOpen: 150, checkInClose: 40, gateOpen: 30, gateClose: 20 } }, - { key: 'british airways', ops: { checkInOpen: 180, checkInClose: 45, gateOpen: 45, gateClose: 20 } }, - { key: 'sas', ops: { checkInOpen: 120, checkInClose: 40, gateOpen: 30, gateClose: 20 } }, - { key: 'scandinavian', ops: { checkInOpen: 120, checkInClose: 40, gateOpen: 30, gateClose: 20 } }, - { key: 'flydubai', ops: { checkInOpen: 180, checkInClose: 60, gateOpen: 40, gateClose: 20 } }, -]; - -function getAirlineOps(name: string): AirlineOps { - const lower = name.toLowerCase(); - return AIRLINE_OPS.find(({ key }) => lower.includes(key))?.ops ?? DEFAULT_OPS; -} -``` - -- [ ] **Step 2: Add ops row inside renderFlight** - -In `renderFlight` (around line 223), find the existing return block. The card currently ends with `` closing the ``. The full card structure is: - -```tsx -return ( - - {duringShift && ...} - - ... - - -); -``` - -Replace the return with: - -```tsx -const ops = activeTab === 'departures' && ts ? getAirlineOps(airline) : null; -const fmt = (offsetMin: number) => - ts ? new Date((ts - offsetMin * 60) * 1000).toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }) : ''; - -return ( - - {duringShift && ⭐ DURANTE IL TUO TURNO} - - - - {airline} - {flightNumber} - {activeTab === 'arrivals' ? 'Da: ' : 'Per: '}{originDest} - - - {time} - - {statusText} - - - - {ops && ( - - - Check-in - {fmt(ops.checkInOpen)} - apre - - - - Check-in - {fmt(ops.checkInClose)} - chiude - - - - Gate - {fmt(ops.gateOpen)} - apre - - - - Gate - {fmt(ops.gateClose)} - chiude - - - )} - -); -``` - -Note: the `ops` and `fmt` variables must be placed **inside** `renderFlight`, just before the `return` statement. - -- [ ] **Step 3: Add ops styles to makeStyles** - -In `makeStyles(c: any)` at the bottom of the file, add these entries inside `StyleSheet.create({...})` alongside the existing styles: - -```typescript -opsRow: { - flexDirection: 'row', - alignItems: 'center', - borderTopWidth: 1, - borderTopColor: c.border, - paddingVertical: 8, - paddingHorizontal: 14, - backgroundColor: c.cardSecondary, -}, -opsCell: { flex: 1, alignItems: 'center', gap: 2 }, -opsDivider: { width: 1, height: 28, backgroundColor: c.border }, -opsLabel: { fontSize: 9, fontWeight: '600', color: c.textMuted, textTransform: 'uppercase', letterSpacing: 0.4 }, -opsTime: { fontSize: 13, fontWeight: '700' }, -opsSub: { fontSize: 9, color: c.textMuted }, -``` - -- [ ] **Step 4: Commit** - -```bash -git add src/screens/FlightScreen.tsx -git commit -m "feat: show check-in and gate times on departure flight cards" -``` - ---- - -## Self-Review - -**Spec coverage:** -- ✅ Departures tab only — `ops` computed only when `activeTab === 'departures'` -- ✅ All 4 times shown — checkInOpen, checkInClose, gateOpen, gateClose -- ✅ Per-airline policy table — `AIRLINE_OPS` with all 7 airlines + default -- ✅ Calculated from departure timestamp — `ts - offsetMin * 60` -- ✅ Layout A (always visible compact row) — `opsRow` with 4 `opsCell` columns -- ✅ Colors: apre=blue, chiude=red, gate apre=amber — applied inline on `opsTime` -- ✅ Themed background — `c.cardSecondary` + `c.border` -- ✅ Single file change — only `FlightScreen.tsx` - -**Placeholder scan:** None found. - -**Type consistency:** `AirlineOps` defined once, used in `AIRLINE_OPS`, `DEFAULT_OPS`, and `getAirlineOps()` return type. `ops` variable typed as `AirlineOps | null`. `fmt` is a local arrow function inside `renderFlight`. All consistent. diff --git a/docs/superpowers/plans/2026-03-25-password-screen.md b/docs/superpowers/plans/2026-03-25-password-screen.md deleted file mode 100644 index 148ba43..0000000 --- a/docs/superpowers/plans/2026-03-25-password-screen.md +++ /dev/null @@ -1,546 +0,0 @@ -# Password Screen Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a Password Manager screen to the hamburger menu, with card-list layout, add/edit/delete modal, show/hide password toggle, and optional 4-digit PIN protection. - -**Architecture:** New `PasswordScreen.tsx` following the same `makeStyles(colors)` + `useMemo` + AsyncStorage pattern used by PhonebookScreen. DrawerMenu and App.tsx get minimal additions to wire it in. - -**Tech Stack:** React Native, Expo, AsyncStorage, MaterialIcons, useAppTheme - ---- - -## File Map - -| Action | File | What changes | -|--------|------|-------------| -| Create | `src/screens/PasswordScreen.tsx` | Full new screen | -| Modify | `src/components/DrawerMenu.tsx` | Add "Password" item to ITEMS | -| Modify | `App.tsx` | Add `'Passwords'` to OverlayScreen type + render branch | - ---- - -### Task 1: Create PasswordScreen.tsx - -**Files:** -- Create: `src/screens/PasswordScreen.tsx` - -- [ ] **Step 1: Write the full PasswordScreen component** - -Create `src/screens/PasswordScreen.tsx` with this content: - -```typescript -import React, { useState, useEffect, useCallback, useMemo } from 'react'; -import { - View, Text, StyleSheet, FlatList, TouchableOpacity, - Modal, TextInput, Alert, KeyboardAvoidingView, Platform, -} from 'react-native'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { MaterialIcons } from '@expo/vector-icons'; -import { useAppTheme } from '../context/ThemeContext'; - -const PASSWORDS_KEY = 'aerostaff_passwords_v1'; -const PIN_KEY = 'aerostaff_pin_v1'; -const PIN_ENABLED_KEY = 'aerostaff_pin_enabled_v1'; - -type PasswordEntry = { - id: string; - name: string; - username: string; - password: string; - notes: string; -}; - -type ModalState = { - visible: boolean; - editingId: string | null; - name: string; - username: string; - password: string; - notes: string; -}; - -const EMPTY_MODAL: ModalState = { - visible: false, editingId: null, - name: '', username: '', password: '', notes: '', -}; - -// ─── PIN Overlay ───────────────────────────────────────────────────────────── -function PinOverlay({ onUnlock, onCancel, title }: { onUnlock: (pin: string) => void; onCancel?: () => void; title: string }) { - const { colors } = useAppTheme(); - const s = useMemo(() => makePinStyles(colors), [colors]); - const [digits, setDigits] = useState(''); - - const press = (d: string) => { - const next = (digits + d).slice(0, 4); - setDigits(next); - if (next.length === 4) { - setTimeout(() => { onUnlock(next); setDigits(''); }, 100); - } - }; - const del = () => setDigits(d => d.slice(0, -1)); - - const keys = ['1','2','3','4','5','6','7','8','9','','0','⌫']; - - return ( - - - - {title} - - {[0,1,2,3].map(i => ( - i && s.dotFilled]} /> - ))} - - - {keys.map((k, i) => ( - k === '' ? : - k === '⌫' ? ( - - - - ) : ( - press(k)} activeOpacity={0.7}> - {k} - - ) - ))} - - {onCancel && ( - - Annulla - - )} - - - ); -} - -// ─── Password Row ───────────────────────────────────────────────────────────── -function PasswordRow({ item, onEdit, onDelete }: { item: PasswordEntry; onEdit: () => void; onDelete: () => void }) { - const { colors } = useAppTheme(); - const s = useMemo(() => makeRowStyles(colors), [colors]); - const [revealed, setRevealed] = useState(false); - - return ( - - - {item.name} - {item.username ? {item.username} : null} - - {revealed ? item.password : '••••••••'} - setRevealed(r => !r)} style={s.eyeBtn}> - - - - {item.notes ? {item.notes} : null} - - - - - - - - - - - ); -} - -// ─── Main Screen ────────────────────────────────────────────────────────────── -export default function PasswordScreen() { - const { colors } = useAppTheme(); - const s = useMemo(() => makeStyles(colors), [colors]); - - const [entries, setEntries] = useState([]); - const [modal, setModal] = useState(EMPTY_MODAL); - const [showPw, setShowPw] = useState(false); - const [pinEnabled, setPinEnabled] = useState(false); - const [pinUnlocked, setPinUnlocked] = useState(false); - const [pinMode, setPinMode] = useState<'unlock' | 'setup' | null>(null); - - // Load on mount - useEffect(() => { - (async () => { - const raw = await AsyncStorage.getItem(PASSWORDS_KEY); - if (raw) setEntries(JSON.parse(raw)); - const enabled = await AsyncStorage.getItem(PIN_ENABLED_KEY); - const isEnabled = enabled === 'true'; - setPinEnabled(isEnabled); - if (!isEnabled) setPinUnlocked(true); - else setPinMode('unlock'); - })(); - }, []); - - const persist = useCallback(async (next: PasswordEntry[]) => { - setEntries(next); - await AsyncStorage.setItem(PASSWORDS_KEY, JSON.stringify(next)); - }, []); - - // PIN toggle - const togglePin = useCallback(async () => { - if (pinEnabled) { - Alert.alert('Disattiva PIN', 'Vuoi rimuovere la protezione PIN?', [ - { text: 'Annulla', style: 'cancel' }, - { text: 'Disattiva', style: 'destructive', onPress: async () => { - setPinEnabled(false); - await AsyncStorage.setItem(PIN_ENABLED_KEY, 'false'); - await AsyncStorage.removeItem(PIN_KEY); - }}, - ]); - } else { - setPinMode('setup'); - } - }, [pinEnabled]); - - const handlePinSetup = useCallback(async (pin: string) => { - await AsyncStorage.setItem(PIN_KEY, pin); - await AsyncStorage.setItem(PIN_ENABLED_KEY, 'true'); - setPinEnabled(true); - setPinMode(null); - Alert.alert('PIN impostato', 'La schermata password è ora protetta.'); - }, []); - - const handlePinUnlock = useCallback(async (pin: string) => { - const stored = await AsyncStorage.getItem(PIN_KEY); - if (pin === stored) { - setPinUnlocked(true); - setPinMode(null); - } else { - Alert.alert('PIN errato', 'Riprova.'); - } - }, []); - - // CRUD - const openAdd = () => setModal({ ...EMPTY_MODAL, visible: true }); - - const openEdit = useCallback((item: PasswordEntry) => { - setModal({ visible: true, editingId: item.id, name: item.name, username: item.username, password: item.password, notes: item.notes }); - }, []); - - const saveModal = useCallback(async () => { - if (!modal.name.trim()) { Alert.alert('Errore', 'Il nome è obbligatorio.'); return; } - if (!modal.password.trim()) { Alert.alert('Errore', 'La password è obbligatoria.'); return; } - let next: PasswordEntry[]; - if (modal.editingId) { - next = entries.map(e => e.id === modal.editingId - ? { ...e, name: modal.name.trim(), username: modal.username.trim(), password: modal.password.trim(), notes: modal.notes.trim() } - : e); - } else { - const entry: PasswordEntry = { - id: Date.now().toString(), - name: modal.name.trim(), - username: modal.username.trim(), - password: modal.password.trim(), - notes: modal.notes.trim(), - }; - next = [...entries, entry]; - } - await persist(next); - setModal(EMPTY_MODAL); - }, [modal, entries, persist]); - - const deleteEntry = useCallback((id: string) => { - Alert.alert('Elimina', 'Vuoi eliminare questa voce?', [ - { text: 'Annulla', style: 'cancel' }, - { text: 'Elimina', style: 'destructive', onPress: async () => { - await persist(entries.filter(e => e.id !== id)); - }}, - ]); - }, [entries, persist]); - - // PIN overlays (setup and unlock) - if (pinMode === 'unlock') { - return ; - } - if (pinMode === 'setup') { - return setPinMode(null)} />; - } - - return ( - - {/* Toolbar */} - - - - Password - - - - - - - - Aggiungi - - - - - {/* List */} - item.id} - renderItem={({ item }) => ( - openEdit(item)} - onDelete={() => deleteEntry(item.id)} - /> - )} - contentContainerStyle={{ padding: 16, paddingBottom: 32 }} - ListEmptyComponent={ - - - Nessuna password salvata. - Premi "Aggiungi" per iniziare. - - } - showsVerticalScrollIndicator={false} - /> - - {/* Add / Edit modal */} - setModal(EMPTY_MODAL)}> - - - {modal.editingId ? 'Modifica voce' : 'Nuova voce'} - - Nome * - setModal(m => ({ ...m, name: v }))} placeholder="es. Portale HR EasyJet" placeholderTextColor={colors.textMuted} /> - - Username / Email - setModal(m => ({ ...m, username: v }))} placeholder="es. mario.rossi@easyjet.com" placeholderTextColor={colors.textMuted} autoCapitalize="none" keyboardType="email-address" /> - - Password * - - setModal(m => ({ ...m, password: v }))} - placeholder="••••••••" - placeholderTextColor={colors.textMuted} - secureTextEntry={!showPw} - autoCapitalize="none" - /> - setShowPw(p => !p)} style={s.eyeModal}> - - - - - Note - setModal(m => ({ ...m, notes: v }))} placeholder="es. scade ogni 90 giorni…" placeholderTextColor={colors.textMuted} multiline numberOfLines={3} textAlignVertical="top" /> - - - setModal(EMPTY_MODAL)}> - Annulla - - - Salva - - - - - - - ); -} - -// ─── Styles ─────────────────────────────────────────────────────────────────── -function makePinStyles(c: any) { - return StyleSheet.create({ - overlay: { flex: 1, backgroundColor: c.bg, justifyContent: 'center', alignItems: 'center' }, - box: { alignItems: 'center', padding: 32, width: '100%', maxWidth: 320 }, - title: { fontSize: 16, fontWeight: '700', color: c.text, marginBottom: 24 }, - dots: { flexDirection: 'row', gap: 16, marginBottom: 32 }, - dot: { width: 16, height: 16, borderRadius: 8, borderWidth: 2, borderColor: c.primary, backgroundColor: 'transparent' }, - dotFilled: { backgroundColor: c.primary }, - grid: { flexDirection: 'row', flexWrap: 'wrap', width: 240, justifyContent: 'center', gap: 12 }, - key: { width: 64, height: 64, borderRadius: 32, backgroundColor: c.card, borderWidth: 1, borderColor: c.border, justifyContent: 'center', alignItems: 'center' }, - keyEmpty:{ width: 64, height: 64 }, - keyText: { fontSize: 22, fontWeight: '600', color: c.text }, - }); -} - -function makeRowStyles(c: any) { - return StyleSheet.create({ - card: { backgroundColor: c.card, borderRadius: 14, padding: 14, marginBottom: 10, flexDirection: 'row', alignItems: 'flex-start', borderWidth: 1, borderColor: c.border, shadowColor: '#000', shadowOpacity: 0.05, shadowRadius: 6, elevation: 2 }, - cardLeft:{ flex: 1 }, - name: { fontSize: 15, fontWeight: '700', color: c.primaryDark, marginBottom: 2 }, - username:{ fontSize: 12, color: c.textSub, marginBottom: 4 }, - pwRow: { flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 2 }, - pw: { fontSize: 13, color: c.text, letterSpacing: 1 }, - eyeBtn: { padding: 2 }, - notes: { fontSize: 11, color: c.textMuted, fontStyle: 'italic', marginTop: 4 }, - actions: { flexDirection: 'column', gap: 6, marginLeft: 8 }, - editBtn: { width: 32, height: 32, borderRadius: 9, backgroundColor: c.primaryLight, justifyContent: 'center', alignItems: 'center' }, - delBtn: { width: 32, height: 32, borderRadius: 9, backgroundColor: '#FEF2F2', justifyContent: 'center', alignItems: 'center' }, - }); -} - -function makeStyles(c: any) { - return StyleSheet.create({ - root: { flex: 1, backgroundColor: c.bg }, - toolbar: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 16, paddingVertical: 12, backgroundColor: c.card, borderBottomWidth: 1, borderBottomColor: c.border }, - titleRow: { flexDirection: 'row', alignItems: 'center', gap: 8 }, - title: { fontSize: 17, fontWeight: '700', color: c.primaryDark }, - toolbarActions:{ flexDirection: 'row', alignItems: 'center', gap: 8 }, - iconBtn: { width: 36, height: 36, borderRadius: 10, backgroundColor: c.cardSecondary, justifyContent: 'center', alignItems: 'center' }, - iconBtnActive:{ backgroundColor: c.primary }, - addBtn: { flexDirection: 'row', alignItems: 'center', gap: 6, backgroundColor: c.primary, borderRadius: 10, paddingHorizontal: 12, paddingVertical: 8 }, - addBtnTxt: { color: '#fff', fontWeight: '600', fontSize: 13 }, - empty: { alignItems: 'center', marginTop: 80, gap: 8 }, - emptyTxt: { fontSize: 16, fontWeight: '600', color: c.textSub }, - emptySubTxt: { fontSize: 13, color: c.textMuted }, - modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' }, - modalBox: { backgroundColor: c.card, borderTopLeftRadius: 24, borderTopRightRadius: 24, padding: 24, paddingBottom: Platform.OS === 'ios' ? 40 : 24 }, - modalTitle: { fontSize: 18, fontWeight: '700', color: c.primaryDark, marginBottom: 20 }, - label: { fontSize: 12, fontWeight: '600', color: c.textSub, marginBottom: 6, textTransform: 'uppercase', letterSpacing: 0.5 }, - input: { backgroundColor: c.bg, borderWidth: 1, borderColor: c.border, borderRadius: 10, paddingHorizontal: 14, paddingVertical: 10, fontSize: 15, color: c.text, marginBottom: 14 }, - inputMulti: { height: 80, paddingTop: 10 }, - pwInputRow: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 14 }, - eyeModal: { padding: 10 }, - modalBtns: { flexDirection: 'row', gap: 10, marginTop: 8 }, - cancelBtn: { flex: 1, paddingVertical: 14, borderRadius: 12, backgroundColor: c.bg, alignItems: 'center', borderWidth: 1, borderColor: c.border }, - cancelTxt: { fontSize: 15, fontWeight: '600', color: c.textSub }, - saveBtn: { flex: 1, paddingVertical: 14, borderRadius: 12, backgroundColor: c.primary, alignItems: 'center' }, - saveTxt: { fontSize: 15, fontWeight: '700', color: '#fff' }, - }); -} -``` - -- [ ] **Step 2: Verify file created correctly** - -Check that `src/screens/PasswordScreen.tsx` exists and has no obvious syntax errors by reviewing imports and exports. - ---- - -### Task 2: Add Password item to DrawerMenu - -**Files:** -- Modify: `src/components/DrawerMenu.tsx` - -- [ ] **Step 1: Add Password to ITEMS array** - -In `src/components/DrawerMenu.tsx`, find the `ITEMS` constant: - -```typescript -const ITEMS: DrawerItem[] = [ - { id: 'Notepad', icon: 'edit-note', label: 'Blocco Note', sublabel: 'Note personali' }, - { id: 'Phonebook', icon: 'contacts', label: 'Rubrica', sublabel: 'Numeri utili' }, - { id: 'Manuals', icon: 'menu-book', label: 'Manuali DCS', sublabel: 'Easyjet, Wizz, Ryanair…' }, - { id: 'Settings', icon: 'settings', label: 'Impostazioni', sublabel: 'Preferenze app' }, -]; -``` - -Replace with: - -```typescript -const ITEMS: DrawerItem[] = [ - { id: 'Notepad', icon: 'edit-note', label: 'Blocco Note', sublabel: 'Note personali' }, - { id: 'Phonebook', icon: 'contacts', label: 'Rubrica', sublabel: 'Numeri utili' }, - { id: 'Passwords', icon: 'lock', label: 'Password', sublabel: 'Credenziali salvate' }, - { id: 'Manuals', icon: 'menu-book', label: 'Manuali DCS', sublabel: 'Easyjet, Wizz, Ryanair…' }, - { id: 'Settings', icon: 'settings', label: 'Impostazioni', sublabel: 'Preferenze app' }, -]; -``` - -- [ ] **Step 2: Commit DrawerMenu change** - -```bash -git add src/components/DrawerMenu.tsx -git commit -m "feat: add Password item to drawer menu" -``` - ---- - -### Task 3: Wire PasswordScreen into App.tsx - -**Files:** -- Modify: `App.tsx` - -- [ ] **Step 1: Add import** - -Add after the existing screen imports at the top of `App.tsx`: - -```typescript -import PasswordScreen from './src/screens/PasswordScreen'; -``` - -- [ ] **Step 2: Extend OverlayScreen type** - -Find: -```typescript -type OverlayScreen = 'Notepad' | 'Phonebook' | 'Manuals' | 'Settings' | null; -``` - -Replace with: -```typescript -type OverlayScreen = 'Notepad' | 'Phonebook' | 'Passwords' | 'Manuals' | 'Settings' | null; -``` - -- [ ] **Step 3: Add to OVERLAY_TITLES** - -Find: -```typescript -const OVERLAY_TITLES: Record, string> = { - Notepad: 'Blocco Note', - Phonebook: 'Rubrica', - Manuals: 'Manuali DCS', - Settings: 'Impostazioni', -}; -``` - -Replace with: -```typescript -const OVERLAY_TITLES: Record, string> = { - Notepad: 'Blocco Note', - Phonebook: 'Rubrica', - Passwords: 'Password', - Manuals: 'Manuali DCS', - Settings: 'Impostazioni', -}; -``` - -- [ ] **Step 4: Add render branch in renderScreen()** - -Find: -```typescript - if (overlay === 'Notepad') return ; - if (overlay === 'Phonebook') return ; - if (overlay === 'Manuals') return ; - if (overlay === 'Settings') return ; -``` - -Replace with: -```typescript - if (overlay === 'Notepad') return ; - if (overlay === 'Phonebook') return ; - if (overlay === 'Passwords') return ; - if (overlay === 'Manuals') return ; - if (overlay === 'Settings') return ; -``` - -- [ ] **Step 5: Commit all remaining changes** - -```bash -git add src/screens/PasswordScreen.tsx App.tsx -git commit -m "feat: add Password Manager screen with PIN protection" -``` - ---- - -## Self-Review - -**Spec coverage:** -- ✅ 4 campi (nome, username, password, note) — `PasswordEntry` type + modal -- ✅ Layout card compatta (A) — `PasswordRow` component -- ✅ Show/hide password per card — `revealed` state in `PasswordRow` -- ✅ Add/edit/delete modal — `saveModal`, `deleteEntry`, `openEdit` -- ✅ PIN opzionale — `PinOverlay` component, `togglePin`, setup+unlock flow -- ✅ AsyncStorage persistence — `PASSWORDS_KEY`, `PIN_KEY`, `PIN_ENABLED_KEY` -- ✅ Theming — `makeStyles`/`makePinStyles`/`makeRowStyles` + `useMemo` -- ✅ DrawerMenu entry — Task 2 -- ✅ App.tsx wiring — Task 3 - -**Placeholder scan:** No TBDs or incomplete sections. - -**Type consistency:** `PasswordEntry`, `ModalState`, `EMPTY_MODAL` defined once in Task 1, referenced consistently. `makeStyles`/`makeRowStyles`/`makePinStyles` all accept `c: any` matching project convention. diff --git a/docs/superpowers/plans/2026-03-26-android-widget.md b/docs/superpowers/plans/2026-03-26-android-widget.md deleted file mode 100644 index a8469e4..0000000 --- a/docs/superpowers/plans/2026-03-26-android-widget.md +++ /dev/null @@ -1,565 +0,0 @@ -# Android Widget — Shift Flights Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a 4x4 Android home screen widget showing today's shift flights with CI/Gate times. - -**Architecture:** `react-native-android-widget` provides JSX-based widget rendering via Expo config plugin. A background task handler reads the device calendar for today's shift, fetches FlightRadar24 departures, filters by allowed airlines and shift window, and renders a scrollable flight list. Reuses `src/utils/airlineOps.ts` for shared constants. - -**Tech Stack:** react-native-android-widget, expo-calendar, FlightRadar24 API, Expo config plugin - ---- - -### Task 1: Install library and configure project - -**Files:** -- Modify: `app.json` -- Modify: `index.ts` - -- [ ] **Step 1: Install react-native-android-widget** - -```bash -npm install react-native-android-widget -``` - -- [ ] **Step 2: Create placeholder widget preview image** - -```bash -cd "C:\Users\turni\Documents\Progetti Antigravity\FlightWorkApp" -cp assets/icon.png assets/widget-preview.png -``` - -- [ ] **Step 3: Add widget config plugin to `app.json`** - -In `app.json`, add the `react-native-android-widget` entry to the `plugins` array, after the existing `expo-notifications` entry: - -```json -[ - "react-native-android-widget", - { - "widgets": [ - { - "name": "ShiftFlights", - "label": "Turno Voli", - "minWidth": "250dp", - "minHeight": "250dp", - "targetCellWidth": 4, - "targetCellHeight": 4, - "description": "Voli del turno corrente con orari CI e Gate", - "previewImage": "./assets/widget-preview.png", - "updatePeriodMillis": 1800000, - "resizeMode": "horizontal|vertical" - } - ] - } -] -``` - -The full `plugins` array should be: - -```json -"plugins": [ - [ - "expo-notifications", - { - "icon": "./assets/icon.png", - "color": "#2563EB", - "defaultChannel": "voli" - } - ], - [ - "react-native-android-widget", - { - "widgets": [ - { - "name": "ShiftFlights", - "label": "Turno Voli", - "minWidth": "250dp", - "minHeight": "250dp", - "targetCellWidth": 4, - "targetCellHeight": 4, - "description": "Voli del turno corrente con orari CI e Gate", - "previewImage": "./assets/widget-preview.png", - "updatePeriodMillis": 1800000, - "resizeMode": "horizontal|vertical" - } - ] - } - ] -] -``` - -- [ ] **Step 4: Register widget task handler in `index.ts`** - -Replace `index.ts` content with: - -```typescript -import { registerRootComponent } from 'expo'; -import { registerWidgetTaskHandler } from 'react-native-android-widget'; - -import App from './App'; -import { widgetTaskHandler } from './src/widgets/widgetTaskHandler'; - -registerRootComponent(App); -registerWidgetTaskHandler(widgetTaskHandler); -``` - -- [ ] **Step 5: Commit** - -```bash -git add app.json index.ts assets/widget-preview.png package.json package-lock.json -git commit -m "feat: install react-native-android-widget and configure ShiftFlights widget" -``` - ---- - -### Task 2: Create widget task handler (data fetching) - -**Files:** -- Create: `src/widgets/widgetTaskHandler.ts` - -- [ ] **Step 1: Create `src/widgets/widgetTaskHandler.ts`** - -```typescript -import React from 'react'; -import * as Calendar from 'expo-calendar'; -import type { WidgetTaskHandlerProps } from 'react-native-android-widget'; -import { getAirlineOps, getAirlineColor, ALLOWED_AIRLINES } from '../utils/airlineOps'; -import { ShiftWidget } from './ShiftWidget'; - -// ─── Types ────────────────────────────────────────────────────────────────────── -export type WidgetFlight = { - flightNumber: string; - destinationIata: string; - departureTime: string; - ciOpen: string; - ciClose: string; - gateOpen: string; - gateClose: string; - airlineColor: string; - departureTs: number; -}; - -export type WidgetData = - | { state: 'work'; shiftLabel: string; flights: WidgetFlight[]; updatedAt: string } - | { state: 'work_empty'; shiftLabel: string; updatedAt: string } - | { state: 'rest' } - | { state: 'no_shift' } - | { state: 'error' }; - -// ─── Helpers ──────────────────────────────────────────────────────────────────── -function fmtTime(ts: number): string { - return new Date(ts * 1000).toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }); -} - -function fmtOffset(depTs: number, offsetMin: number): string { - return fmtTime(depTs - offsetMin * 60); -} - -function nowHHMM(): string { - return new Date().toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }); -} - -// ─── Fetch shift from device calendar ─────────────────────────────────────────── -async function fetchTodayShift(): Promise< - | { type: 'work'; start: Date; end: Date } - | { type: 'rest' } - | null -> { - const { status } = await Calendar.requestCalendarPermissionsAsync(); - if (status !== 'granted') return null; - - const cals = await Calendar.getCalendarsAsync(Calendar.EntityTypes.EVENT); - const cal = cals.find(c => c.allowsModifications && c.isPrimary) || cals.find(c => c.allowsModifications); - if (!cal) return null; - - const today = new Date(); - today.setHours(0, 0, 0, 0); - const todayEnd = new Date(today); - todayEnd.setHours(23, 59, 59, 999); - - const events = await Calendar.getEventsAsync([cal.id], today, todayEnd); - const shift = events.find(e => e.title.includes('Lavoro') || e.title.includes('Riposo')); - if (!shift) return null; - - if (shift.title.includes('Riposo')) return { type: 'rest' }; - return { type: 'work', start: new Date(shift.startDate), end: new Date(shift.endDate) }; -} - -// ─── Fetch departures from FlightRadar24 ──────────────────────────────────────── -async function fetchDepartures(): Promise { - const res = await fetch( - 'https://api.flightradar24.com/common/v1/airport.json?code=psa&plugin[]=schedule&page=1&limit=100', - { headers: { 'User-Agent': 'Mozilla/5.0' } }, - ); - const json = await res.json(); - const data: any[] = json.result?.response?.airport?.pluginData?.schedule?.departures?.data || []; - return data.filter(item => - ALLOWED_AIRLINES.some(k => (item.flight?.airline?.name || '').toLowerCase().includes(k)), - ); -} - -// ─── Build widget data ────────────────────────────────────────────────────────── -async function buildWidgetData(): Promise { - const shift = await fetchTodayShift(); - if (shift === null) return { state: 'no_shift' }; - if (shift.type === 'rest') return { state: 'rest' }; - - const shiftStartTs = shift.start.getTime() / 1000; - const shiftEndTs = shift.end.getTime() / 1000; - const shiftLabel = `${fmtTime(shiftStartTs)} – ${fmtTime(shiftEndTs)}`; - - const allDepartures = await fetchDepartures(); - - const flights: WidgetFlight[] = allDepartures - .filter(item => { - const ts: number | undefined = item.flight?.time?.scheduled?.departure; - return ts != null && ts >= shiftStartTs && ts <= shiftEndTs; - }) - .map(item => { - const ts: number = item.flight.time.scheduled.departure; - const airline: string = item.flight?.airline?.name || 'Sconosciuta'; - const ops = getAirlineOps(airline); - return { - flightNumber: item.flight?.identification?.number?.default || 'N/A', - destinationIata: item.flight?.airport?.destination?.code?.iata || '???', - departureTs: ts, - departureTime: fmtTime(ts), - ciOpen: fmtOffset(ts, ops.checkInOpen), - ciClose: fmtOffset(ts, ops.checkInClose), - gateOpen: fmtOffset(ts, ops.gateOpen), - gateClose: fmtOffset(ts, ops.gateClose), - airlineColor: getAirlineColor(airline), - }; - }) - .sort((a, b) => a.departureTs - b.departureTs); - - if (flights.length === 0) { - return { state: 'work_empty', shiftLabel, updatedAt: nowHHMM() }; - } - - return { state: 'work', shiftLabel, flights, updatedAt: nowHHMM() }; -} - -// ─── Task handler ─────────────────────────────────────────────────────────────── -export async function widgetTaskHandler(props: WidgetTaskHandlerProps) { - switch (props.widgetAction) { - case 'WIDGET_ADDED': - case 'WIDGET_UPDATE': - case 'WIDGET_RESIZED': { - let data: WidgetData; - try { - data = await buildWidgetData(); - } catch { - data = { state: 'error' }; - } - props.renderWidget(); - break; - } - - case 'WIDGET_CLICK': { - if (props.clickAction === 'REFRESH') { - let data: WidgetData; - try { - data = await buildWidgetData(); - } catch { - data = { state: 'error' }; - } - props.renderWidget(); - } - break; - } - - case 'WIDGET_DELETED': - default: - break; - } -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add src/widgets/widgetTaskHandler.ts -git commit -m "feat: add widget task handler with calendar and flight data fetching" -``` - ---- - -### Task 3: Create widget JSX component (UI) - -**Files:** -- Create: `src/widgets/ShiftWidget.tsx` - -- [ ] **Step 1: Create `src/widgets/ShiftWidget.tsx`** - -```tsx -import React from 'react'; -import { FlexWidget, TextWidget, ListWidget } from 'react-native-android-widget'; -import type { WidgetData, WidgetFlight } from './widgetTaskHandler'; - -const BG = '#0F172A'; -const HEADER_BG = '#1E293B'; -const TEXT = '#F1F5F9'; -const MUTED = '#94A3B8'; -const ORANGE = '#F59E0B'; -const BLUE = '#3B82F6'; - -function FlightRow({ flight, index }: { flight: WidgetFlight; index: number }) { - return ( - - {/* Flight number + destination + departure */} - - - - - - - - {/* CI + Gate times */} - - - - - - ); -} - -export function ShiftWidget({ data }: { data: WidgetData }) { - // ── Rest day ── - if (data.state === 'rest') { - return ( - - - - - ); - } - - // ── No shift ── - if (data.state === 'no_shift') { - return ( - - - - ); - } - - // ── Error ── - if (data.state === 'error') { - return ( - - - - - ); - } - - // ── Work shift, no flights ── - if (data.state === 'work_empty') { - return ( - - - - - - - - - - - - ); - } - - // ── Work shift with flights ── - return ( - - {/* Header */} - - - - - {/* Scrollable flight list */} - - {data.flights.map((flight, i) => ( - - ))} - - - {/* Footer */} - - - - - ); -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add src/widgets/ShiftWidget.tsx -git commit -m "feat: create ShiftWidget component with all visual states" -``` - ---- - -### Task 4: Build and verify - -- [ ] **Step 1: Rebuild native project** - -```bash -npx expo prebuild --clean -npx expo run:android -``` - -Or for EAS: - -```bash -eas build --profile development --platform android -``` - -- [ ] **Step 2: Add widget to home screen** - -1. Long-press Android home screen -2. Select "Widgets" -3. Find "FlightWorkApp" > "Turno Voli" -4. Drag 4x4 widget to home screen -5. Widget should fetch and render immediately - -- [ ] **Step 3: Verify all states** - -| State | Trigger | Expected | -|---|---|---| -| Work + flights | Lavoro event today during flight hours | Header + scrollable flight list + footer | -| Work + empty | Lavoro event at 02:00-04:00 (no flights) | Header + "Nessuna partenza" + footer | -| Rest | Riposo event today | Palm + "Giorno di Riposo" | -| No shift | No shift event today | "Nessun turno oggi" | -| Error | Airplane mode | "Aggiornamento fallito" + "Tocca per riprovare" | - -- [ ] **Step 4: Commit any fixes** - -```bash -git add -A -git commit -m "fix: widget adjustments after testing" -``` diff --git a/docs/superpowers/plans/2026-03-26-shift-task-timeline.md b/docs/superpowers/plans/2026-03-26-shift-task-timeline.md deleted file mode 100644 index 7066ade..0000000 --- a/docs/superpowers/plans/2026-03-26-shift-task-timeline.md +++ /dev/null @@ -1,498 +0,0 @@ -# Shift Task Timeline Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a bottom sheet with a visual timeline of departure flights during the current shift, triggered from "Dettagli Task" in HomeScreen. - -**Architecture:** New `ShiftTimeline.tsx` component renders a Modal bottom sheet. It fetches departures from FlightRadar24, filters by shift time range, and renders a vertical timeline with colored bars for check-in/gate windows. HomeScreen passes shift start/end and controls visibility. - -**Tech Stack:** React Native Animated, Modal, ScrollView, fetch API, expo-calendar (shift data already available in HomeScreen) - ---- - -### Task 1: Extract shared airline constants to a shared module - -**Files:** -- Create: `src/utils/airlineOps.ts` -- Modify: `src/screens/FlightScreen.tsx:31-59` - -- [ ] **Step 1: Create `src/utils/airlineOps.ts`** - -```typescript -export type AirlineOps = { - checkInOpen: number; - checkInClose: number; - gateOpen: number; - gateClose: number; -}; - -export const DEFAULT_OPS: AirlineOps = { checkInOpen: 120, checkInClose: 40, gateOpen: 30, gateClose: 20 }; - -export const AIRLINE_OPS: Array<{ key: string; ops: AirlineOps }> = [ - { key: 'easyjet', ops: { checkInOpen: 120, checkInClose: 40, gateOpen: 30, gateClose: 20 } }, - { key: 'wizz', ops: { checkInOpen: 180, checkInClose: 40, gateOpen: 30, gateClose: 15 } }, - { key: 'ryanair', ops: { checkInOpen: 150, checkInClose: 40, gateOpen: 30, gateClose: 20 } }, - { key: 'aer lingus', ops: { checkInOpen: 150, checkInClose: 40, gateOpen: 30, gateClose: 20 } }, - { key: 'british airways', ops: { checkInOpen: 180, checkInClose: 45, gateOpen: 45, gateClose: 20 } }, - { key: 'sas', ops: { checkInOpen: 120, checkInClose: 40, gateOpen: 30, gateClose: 20 } }, - { key: 'scandinavian', ops: { checkInOpen: 120, checkInClose: 40, gateOpen: 30, gateClose: 20 } }, - { key: 'flydubai', ops: { checkInOpen: 180, checkInClose: 60, gateOpen: 40, gateClose: 20 } }, -]; - -export function getAirlineOps(name: string): AirlineOps { - const lower = name.toLowerCase(); - return AIRLINE_OPS.find(({ key }) => lower.includes(key))?.ops ?? DEFAULT_OPS; -} - -export const AIRLINE_COLORS: Record = { - 'wizz': '#C6006E', 'easyjet': '#FF6600', 'aer lingus': '#006E44', - 'british airways': '#075AAA', 'sas': '#003E7E', 'scandinavian': '#003E7E', 'flydubai': '#CC1E42', -}; - -export function getAirlineColor(name: string): string { - const lower = name.toLowerCase(); - for (const [k, c] of Object.entries(AIRLINE_COLORS)) if (lower.includes(k)) return c; - return '#2563EB'; -} - -export const ALLOWED_AIRLINES = ['wizz', 'aer lingus', 'easyjet', 'british airways', 'sas', 'scandinavian', 'flydubai']; -``` - -- [ ] **Step 2: Update FlightScreen.tsx imports** - -Replace lines 31-59 (the local `airlineColors`, `getAirlineColor`, `AirlineOps`, `DEFAULT_OPS`, `AIRLINE_OPS`, `getAirlineOps` definitions) with: - -```typescript -import { getAirlineOps, getAirlineColor, ALLOWED_AIRLINES } from '../utils/airlineOps'; -``` - -Also update the `allowed` constant inside `fetchAll` (line 166): - -```typescript -const filter = (data: any[]) => data.filter(i => ALLOWED_AIRLINES.some(k => (i.flight?.airline?.name || '').toLowerCase().includes(k))); -``` - -- [ ] **Step 3: Verify FlightScreen still works** - -Run the app, navigate to the Voli tab, confirm flights load and display correctly. - -- [ ] **Step 4: Commit** - -```bash -git add src/utils/airlineOps.ts src/screens/FlightScreen.tsx -git commit -m "refactor: extract airline ops and colors to shared module" -``` - ---- - -### Task 2: Create ShiftTimeline component — fetch + timeline rendering - -**Files:** -- Create: `src/components/ShiftTimeline.tsx` - -- [ ] **Step 1: Create `src/components/ShiftTimeline.tsx`** - -```typescript -import React, { useState, useEffect, useMemo, useCallback } from 'react'; -import { - View, Text, StyleSheet, Modal, ScrollView, TouchableOpacity, - ActivityIndicator, Dimensions, LayoutAnimation, Platform, UIManager, -} from 'react-native'; -import { MaterialIcons } from '@expo/vector-icons'; -import { useAppTheme } from '../context/ThemeContext'; -import { getAirlineOps, getAirlineColor, ALLOWED_AIRLINES } from '../utils/airlineOps'; - -if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) { - UIManager.setLayoutAnimationEnabledExperimental(true); -} - -const SCREEN_H = Dimensions.get('window').height; -const CI_COLOR = '#F59E0B'; -const GATE_COLOR = '#3B82F6'; - -type Props = { - visible: boolean; - onClose: () => void; - shiftStart: Date; - shiftEnd: Date; -}; - -type Flight = { - id: string; - flightNumber: string; - airlineName: string; - airlineIata: string; - destination: string; - departureTs: number; // Unix seconds - status: string; - statusColor: string; - ops: { checkInOpen: number; checkInClose: number; gateOpen: number; gateClose: number }; -}; - -function parseFlight(item: any): Flight | null { - const f = item.flight; - if (!f) return null; - const ts = f.time?.scheduled?.departure; - if (!ts) return null; - const airlineName = f.airline?.name || 'Sconosciuta'; - return { - id: f.identification?.id || `${ts}`, - flightNumber: f.identification?.number?.default || 'N/A', - airlineName, - airlineIata: f.airline?.code?.iata || '', - destination: f.airport?.destination?.code?.iata || f.airport?.destination?.name || '???', - departureTs: ts, - status: f.status?.text || 'Scheduled', - statusColor: f.status?.generic?.status?.color || 'gray', - ops: getAirlineOps(airlineName), - }; -} - -export default function ShiftTimeline({ visible, onClose, shiftStart, shiftEnd }: Props) { - const { colors } = useAppTheme(); - const [flights, setFlights] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(false); - const [expandedId, setExpandedId] = useState(null); - - const startSec = shiftStart.getTime() / 1000; - const endSec = shiftEnd.getTime() / 1000; - const totalSec = endSec - startSec; - - const fetchFlights = useCallback(async () => { - setLoading(true); - setError(false); - try { - const res = await fetch( - 'https://api.flightradar24.com/common/v1/airport.json?code=psa&plugin[]=schedule&page=1&limit=100', - { headers: { 'User-Agent': 'Mozilla/5.0' } }, - ); - const json = await res.json(); - const raw: any[] = json.result?.response?.airport?.pluginData?.schedule?.departures?.data || []; - const filtered = raw - .filter(i => ALLOWED_AIRLINES.some(k => (i.flight?.airline?.name || '').toLowerCase().includes(k))) - .map(parseFlight) - .filter((f): f is Flight => f !== null && f.departureTs >= startSec && f.departureTs <= endSec) - .sort((a, b) => a.departureTs - b.departureTs); - setFlights(filtered); - } catch { - setError(true); - } finally { - setLoading(false); - } - }, [startSec, endSec]); - - useEffect(() => { - if (visible) fetchFlights(); - }, [visible, fetchFlights]); - - const toggleExpand = (id: string) => { - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); - setExpandedId(prev => (prev === id ? null : id)); - }; - - // Posizione verticale: percentuale nel turno - const yPercent = (ts: number) => ((ts - startSec) / totalSec) * 100; - - // Tacche ogni 30 minuti - const ticks = useMemo(() => { - const result: { label: string; pct: number }[] = []; - const first = new Date(shiftStart); - first.setMinutes(Math.ceil(first.getMinutes() / 30) * 30, 0, 0); - let t = first.getTime() / 1000; - while (t <= endSec) { - if (t >= startSec) { - result.push({ - label: new Date(t * 1000).toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }), - pct: yPercent(t), - }); - } - t += 30 * 60; - } - return result; - }, [startSec, endSec]); - - const nowSec = Date.now() / 1000; - const showNowLine = nowSec >= startSec && nowSec <= endSec; - - const fmtTime = (ts: number) => new Date(ts * 1000).toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }); - - const TIMELINE_HEIGHT = Math.max(500, flights.length * 90); - - const s = useMemo(() => makeStyles(colors), [colors]); - - return ( - - - - {/* Handle */} - - - - - {/* Header */} - - - - Voli nel Turno - - - {fmtTime(startSec)} – {fmtTime(endSec)} - - - - - - - - {/* Legenda */} - - - - Check-in - - - - Gate - - - - {/* Content */} - {loading ? ( - - - - ) : error ? ( - - Errore nel caricamento - - Riprova - - - ) : flights.length === 0 ? ( - - ✈️ - Nessuna partenza nel turno - - ) : ( - - - {/* Asse verticale */} - - - {/* Tacche orarie */} - {ticks.map((tick, i) => ( - - {tick.label} - - - ))} - - {/* Linea "adesso" */} - {showNowLine && ( - - ORA - - - )} - - {/* Voli */} - {flights.map(flight => { - const depPct = yPercent(flight.departureTs); - const ciOpenTs = flight.departureTs - flight.ops.checkInOpen * 60; - const ciCloseTs = flight.departureTs - flight.ops.checkInClose * 60; - const gateOpenTs = flight.departureTs - flight.ops.gateOpen * 60; - const gateCloseTs = flight.departureTs - flight.ops.gateClose * 60; - - // Barre: posizione relativa alla riga - const barWidth = (startTs: number, endTs: number) => { - const dur = endTs - startTs; - return `${Math.min(100, (dur / (180 * 60)) * 100)}%`; - }; - - const expanded = expandedId === flight.id; - const airlineColor = getAirlineColor(flight.airlineName); - - return ( - - toggleExpand(flight.id)} activeOpacity={0.7}> - {/* Label */} - - {flight.flightNumber} · {flight.destination} - - - {/* Barre */} - - - CI - - - Gate - - - - {/* Pallino sulla timeline */} - - - - {/* Card espansa */} - {expanded && ( - - {flight.airlineName} - - Partenza - {fmtTime(flight.departureTs)} - - - CI Open / Close - {fmtTime(ciOpenTs)} – {fmtTime(ciCloseTs)} - - - Gate Open / Close - {fmtTime(gateOpenTs)} – {fmtTime(gateCloseTs)} - - - Stato - - {flight.status} - - - - )} - - ); - })} - - - )} - - - - ); -} - -function makeStyles(c: any) { - return StyleSheet.create({ - overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' }, - sheet: { - height: SCREEN_H * 0.8, borderTopLeftRadius: 24, borderTopRightRadius: 24, - paddingBottom: Platform.OS === 'ios' ? 34 : 16, - }, - handleRow: { alignItems: 'center', paddingTop: 10, paddingBottom: 6 }, - handle: { width: 36, height: 4, borderRadius: 2 }, - header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 20, paddingBottom: 10 }, - title: { fontSize: 18, fontWeight: '800' }, - subtitle: { fontSize: 12, marginTop: 2 }, - closeBtn: { padding: 8, borderRadius: 20 }, - legend: { flexDirection: 'row', gap: 16, paddingHorizontal: 20, paddingBottom: 12 }, - legendItem: { flexDirection: 'row', alignItems: 'center', gap: 6 }, - legendDot: { width: 10, height: 10, borderRadius: 5 }, - legendText: { fontSize: 11, fontWeight: '600' }, - center: { flex: 1, justifyContent: 'center', alignItems: 'center' }, - retryBtn: { paddingHorizontal: 20, paddingVertical: 10, borderRadius: 10 }, - scrollArea: { flex: 1, paddingTop: 8 }, - axis: { position: 'absolute', left: 0, top: 0, bottom: 0, width: 2, borderRadius: 1 }, - tickRow: { position: 'absolute', left: -50, right: 0, flexDirection: 'row', alignItems: 'center' }, - tickLabel: { fontSize: 9, fontWeight: '700', width: 38, textAlign: 'right', marginRight: 8 }, - tickLine: { flex: 1, height: 1, opacity: 0.4 }, - nowLine: { position: 'absolute', left: -50, right: 0, flexDirection: 'row', alignItems: 'center', zIndex: 10 }, - nowLabel: { fontSize: 8, fontWeight: '900', color: '#EF4444', width: 38, textAlign: 'right', marginRight: 8 }, - nowDash: { flex: 1, height: 2, backgroundColor: '#EF4444', opacity: 0.7 }, - flightRow: { position: 'absolute', left: 10, right: 0, transform: [{ translateY: -10 }] }, - flightLabel: { fontSize: 12, fontWeight: '700', marginBottom: 3 }, - barsRow: { flexDirection: 'row', gap: 4, marginBottom: 4 }, - bar: { height: 20, borderRadius: 4, justifyContent: 'center', paddingHorizontal: 6, minWidth: 40 }, - barText: { fontSize: 9, fontWeight: '800', color: '#fff' }, - dot: { position: 'absolute', left: -14, top: 4, width: 10, height: 10, borderRadius: 5, borderWidth: 2 }, - expandedCard: { borderRadius: 10, padding: 12, marginTop: 4, borderWidth: 1 }, - expandedTitle: { fontSize: 14, fontWeight: '700', marginBottom: 8 }, - expandedRow: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 }, - expandedLabel: { fontSize: 11, fontWeight: '600' }, - expandedValue: { fontSize: 11, fontWeight: '700' }, - }); -} -``` - -- [ ] **Step 2: Verify file compiles** - -Save and check for hot-reload errors in the Expo console. - -- [ ] **Step 3: Commit** - -```bash -git add src/components/ShiftTimeline.tsx -git commit -m "feat: add ShiftTimeline bottom sheet component with visual timeline" -``` - ---- - -### Task 3: Wire up "Dettagli Task" button in HomeScreen - -**Files:** -- Modify: `src/screens/HomeScreen.tsx` - -- [ ] **Step 1: Add state and import** - -At the top of HomeScreen.tsx, add import: - -```typescript -import ShiftTimeline from '../components/ShiftTimeline'; -``` - -Inside the component, add state: - -```typescript -const [timelineVisible, setTimelineVisible] = useState(false); -``` - -- [ ] **Step 2: Add onPress to "Dettagli Task" button** - -Find the existing button (around line 396): - -```typescript - - Dettagli Task - - -``` - -Replace with: - -```typescript - setTimelineVisible(true)}> - Dettagli Task - - -``` - -- [ ] **Step 3: Render ShiftTimeline component** - -Before the closing `` at the end of the JSX, add: - -```typescript -{shiftEvent && isWork && ( - setTimelineVisible(false)} - shiftStart={new Date(shiftEvent.startDate)} - shiftEnd={new Date(shiftEvent.endDate)} - /> -)} -``` - -- [ ] **Step 4: Test end-to-end** - -1. Open the app, go to Home -2. On a day with a "Lavoro" shift, tap "Dettagli Task" -3. Bottom sheet opens with timeline -4. Flights during shift show with CI/gate bars -5. Tap a flight → details expand -6. Tap again → collapses -7. Close the sheet - -- [ ] **Step 5: Commit** - -```bash -git add src/screens/HomeScreen.tsx -git commit -m "feat: wire Dettagli Task button to ShiftTimeline bottom sheet" -``` diff --git a/docs/superpowers/plans/2026-03-27-manuals-editable.md b/docs/superpowers/plans/2026-03-27-manuals-editable.md deleted file mode 100644 index fe9f2f7..0000000 --- a/docs/superpowers/plans/2026-03-27-manuals-editable.md +++ /dev/null @@ -1,871 +0,0 @@ -# Manuali DCS Editabili — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Aggiungere CRUD completo (compagnie, sezioni, voci) con persistenza AsyncStorage in ManualsScreen. - -**Architecture:** Tutto in `ManualsScreen.tsx`. Lo state `airlines` viene caricato da AsyncStorage al mount (fallback ai dati hardcoded). Ogni mutazione aggiorna lo state e persiste immediatamente. I modal vengono gestiti con un singolo state `modalState` discriminato per tipo (`airline | section | item`). - -**Tech Stack:** React Native, AsyncStorage (`@react-native-async-storage/async-storage`), Modal RN, Alert RN. - ---- - -### Task 1: Migrazione dati → AsyncStorage - -**Files:** -- Modify: `src/screens/ManualsScreen.tsx` - -- [ ] **Step 1: Aggiungere import AsyncStorage** - -In cima al file, dopo gli import esistenti: - -```typescript -import AsyncStorage from '@react-native-async-storage/async-storage'; -``` - -- [ ] **Step 2: Aggiungere costante chiave storage** - -Subito dopo gli import, prima di `const GOLD`: - -```typescript -const STORAGE_KEY = 'manuals_data'; -``` - -- [ ] **Step 3: Convertire AIRLINES da costante a default** - -Rinominare `AIRLINES` in `DEFAULT_AIRLINES` (replace_all: true nel file): - -```typescript -const DEFAULT_AIRLINES: Airline[] = [ - // ... tutto il contenuto invariato ... -]; -``` - -- [ ] **Step 4: Aggiungere state e caricamento in ManualsScreen** - -Nel componente `ManualsScreen`, sostituire: - -```typescript -const [selectedAirline, setSelectedAirline] = useState(AIRLINES[0].id); -const airline = AIRLINES.find(a => a.id === selectedAirline) ?? AIRLINES[0]; -``` - -con: - -```typescript -const [airlines, setAirlines] = useState(DEFAULT_AIRLINES); -const [loaded, setLoaded] = useState(false); -const [selectedAirline, setSelectedAirline] = useState(DEFAULT_AIRLINES[0].id); - -useEffect(() => { - AsyncStorage.getItem(STORAGE_KEY).then(raw => { - if (raw) { - try { - const parsed: Airline[] = JSON.parse(raw); - if (parsed.length > 0) { - setAirlines(parsed); - setSelectedAirline(parsed[0].id); - } - } catch {} - } else { - AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(DEFAULT_AIRLINES)); - } - setLoaded(true); - }); -}, []); -``` - -Aggiungere `useEffect` agli import React: -```typescript -import React, { useState, useMemo, useEffect } from 'react'; -``` - -- [ ] **Step 5: Aggiungere funzione persist e aggiornare `airline`** - -Subito dopo i `useState`, aggiungere: - -```typescript -const persist = (updated: Airline[]) => { - setAirlines(updated); - AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); -}; - -const airline = airlines.find(a => a.id === selectedAirline) ?? airlines[0]; -``` - -- [ ] **Step 6: Aggiornare i riferimenti a AIRLINES nel JSX** - -Nel JSX di `ManualsScreen`, sostituire tutti i `AIRLINES` con `airlines`: -- `AIRLINES.map(a => { ... })` → `airlines.map(a => { ... })` - -- [ ] **Step 7: Verificare che l'app carichi e mostri i dati** - -Ricaricare l'app via Metro. La schermata Manuali deve funzionare identicamente a prima. - -- [ ] **Step 8: Commit** - -```bash -git add src/screens/ManualsScreen.tsx -git commit -m "feat: migrate manuals data to AsyncStorage" -``` - ---- - -### Task 2: Edit mode toggle - -**Files:** -- Modify: `src/screens/ManualsScreen.tsx` - -- [ ] **Step 1: Aggiungere state editMode** - -Dopo `const [loaded, setLoaded] = useState(false);`: - -```typescript -const [editMode, setEditMode] = useState(false); -``` - -- [ ] **Step 2: Aggiungere icona ✏️ nell'header** - -Sostituire il blocco header nel JSX: - -```tsx - - - Manuali DCS - setEditMode(v => !v)} style={{ marginLeft: 'auto' }}> - - - -``` - -- [ ] **Step 3: Verificare visivamente** - -Ricaricare l'app. L'icona ✏️ appare nell'header. Toccandola cambia colore (primary = edit mode attivo, textMuted = inattivo). - -- [ ] **Step 4: Commit** - -```bash -git add src/screens/ManualsScreen.tsx -git commit -m "feat: add edit mode toggle to manuals header" -``` - ---- - -### Task 3: Modal state e tipi - -**Files:** -- Modify: `src/screens/ManualsScreen.tsx` - -- [ ] **Step 1: Aggiungere i tipi per il modal state** - -Subito dopo i tipi esistenti (`ManualItem`, `Section`, `Airline`): - -```typescript -type ModalState = - | { kind: 'none' } - | { kind: 'airline_add' } - | { kind: 'airline_edit'; airlineId: string } - | { kind: 'section_add'; airlineId: string } - | { kind: 'section_edit'; airlineId: string; sectionIdx: number } - | { kind: 'item_add'; airlineId: string; sectionIdx: number } - | { kind: 'item_edit'; airlineId: string; sectionIdx: number; itemIdx: number }; -``` - -- [ ] **Step 2: Aggiungere state modal in ManualsScreen** - -Dopo `const [editMode, setEditMode] = useState(false);`: - -```typescript -const [modal, setModal] = useState({ kind: 'none' }); -const closeModal = () => setModal({ kind: 'none' }); -``` - -- [ ] **Step 3: Commit** - -```bash -git add src/screens/ManualsScreen.tsx -git commit -m "feat: add modal state types for manuals CRUD" -``` - ---- - -### Task 4: Modal Compagnia (add/edit/delete) - -**Files:** -- Modify: `src/screens/ManualsScreen.tsx` - -- [ ] **Step 1: Aggiungere palette colori** - -Subito dopo `const STORAGE_KEY`: - -```typescript -const AIRLINE_COLORS = [ - { color: '#FF6600', textColor: '#fff' }, // easyJet orange - { color: '#C01380', textColor: '#fff' }, // Wizz pink - { color: '#003580', textColor: '#fff' }, // Ryanair blue - { color: '#F7C800', textColor: '#1a1a1a' }, // Vueling yellow - { color: '#006DBF', textColor: '#fff' }, // blue - { color: '#2E7D32', textColor: '#fff' }, // green - { color: '#B71C1C', textColor: '#fff' }, // red - { color: '#4A148C', textColor: '#fff' }, // purple - { color: '#E65100', textColor: '#fff' }, // deep orange - { color: '#37474F', textColor: '#fff' }, // grey -]; -``` - -- [ ] **Step 2: Creare il componente AirlineModal** - -Aggiungere prima del componente `ManualsScreen`: - -```tsx -function AirlineModal({ - modal, airlines, persist, closeModal, -}: { - modal: ModalState; - airlines: Airline[]; - persist: (a: Airline[]) => void; - closeModal: () => void; -}) { - const { colors } = useAppTheme(); - const isEdit = modal.kind === 'airline_edit'; - const existing = isEdit ? airlines.find(a => a.id === (modal as any).airlineId) : undefined; - - const [name, setName] = useState(existing?.name ?? ''); - const [code, setCode] = useState(existing?.code ?? ''); - const [colorIdx, setColorIdx] = useState(() => { - if (!existing) return 0; - return AIRLINE_COLORS.findIndex(c => c.color === existing.color) ?? 0; - }); - - const visible = modal.kind === 'airline_add' || modal.kind === 'airline_edit'; - - const save = () => { - if (!name.trim() || !code.trim()) return; - const chosen = AIRLINE_COLORS[colorIdx] ?? AIRLINE_COLORS[0]; - if (isEdit) { - const updated = airlines.map(a => - a.id === (modal as any).airlineId - ? { ...a, name: name.trim(), code: code.trim().toUpperCase(), color: chosen.color, textColor: chosen.textColor } - : a - ); - persist(updated); - } else { - const newAirline: Airline = { - id: Date.now().toString(), - name: name.trim(), - code: code.trim().toUpperCase(), - color: chosen.color, - textColor: chosen.textColor, - sections: [], - }; - persist([...airlines, newAirline]); - } - closeModal(); - }; - - const del = () => { - Alert.alert( - 'Elimina compagnia', - `Eliminare "${existing?.name}" e tutti i suoi contenuti?`, - [ - { text: 'Annulla', style: 'cancel' }, - { - text: 'Elimina', style: 'destructive', - onPress: () => { - persist(airlines.filter(a => a.id !== (modal as any).airlineId)); - closeModal(); - }, - }, - ] - ); - }; - - return ( - - - - - {isEdit ? 'Modifica compagnia' : 'Nuova compagnia'} - - - Nome - - - Codice IATA - - - Colore - - {AIRLINE_COLORS.map((c, i) => ( - setColorIdx(i)} - /> - ))} - - - - {isEdit && ( - - Elimina - - )} - - Annulla - - - Salva - - - - - - ); -} -``` - -- [ ] **Step 3: Aggiungere `modalStyles` e `TextInput` agli import** - -Aggiungere `TextInput, Modal, Alert` agli import React Native (se non già presenti): - -```typescript -import { View, Text, StyleSheet, TouchableOpacity, ScrollView, - LayoutAnimation, Platform, UIManager, TextInput, Modal, Alert } from 'react-native'; -``` - -Aggiungere subito prima di `makeStyles`: - -```typescript -const modalStyles = StyleSheet.create({ - overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' }, - sheet: { borderTopLeftRadius: 20, borderTopRightRadius: 20, padding: 20, paddingBottom: 36 }, - title: { fontSize: 17, fontWeight: '700', marginBottom: 16 }, - label: { fontSize: 12, fontWeight: '600', marginBottom: 4, marginTop: 12 }, - input: { borderWidth: 1, borderRadius: 8, paddingHorizontal: 12, paddingVertical: 9, fontSize: 14 }, - colorRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 10, marginTop: 8 }, - colorDot: { width: 28, height: 28, borderRadius: 14 }, - colorDotSelected: { borderWidth: 3, borderColor: '#000', transform: [{ scale: 1.2 }] }, - btnRow: { flexDirection: 'row', justifyContent: 'flex-end', gap: 8, marginTop: 20 }, - btn: { paddingHorizontal: 18, paddingVertical: 9, borderRadius: 8 }, - btnCancel: { backgroundColor: 'transparent', borderWidth: 1, borderColor: '#ccc' }, - btnSave: {}, - btnDanger: { marginRight: 'auto', backgroundColor: '#FEE2E2' }, - btnText: { fontSize: 14, fontWeight: '600' }, - btnDangerText: { fontSize: 14, fontWeight: '600', color: '#DC2626' }, -}); -``` - -- [ ] **Step 4: Rendere il chip `+` e pressione lunga visibili in edit mode** - -Nella chip bar del JSX, aggiungere dopo `{airlines.map(...)}`: - -```tsx -{editMode && ( - setModal({ kind: 'airline_add' })} - activeOpacity={0.8} - > - - Aggiungi - -)} -``` - -Sulla `TouchableOpacity` del chip esistente, aggiungere `onLongPress`: - -```tsx -onLongPress={editMode ? () => setModal({ kind: 'airline_edit', airlineId: a.id }) : undefined} -``` - -- [ ] **Step 5: Aggiungere AirlineModal nel render condizionalmente** - -Subito prima del `` di chiusura del componente `ManualsScreen`. Il rendering condizionale garantisce che il componente si rimonta ogni volta che il modal si apre, evitando stato stale: - -```tsx -{(modal.kind === 'airline_add' || modal.kind === 'airline_edit') && ( - -)} -``` - -- [ ] **Step 6: Testare add e edit compagnia** - -Ricaricare l'app. In edit mode: -- Toccare `+ Aggiungi` → si apre il modal, compilare nome/codice/colore → Salva → il chip appare nella bar. -- Pressione lunga su chip esistente → si apre modal modifica. -- Eliminare con conferma. - -- [ ] **Step 7: Commit** - -```bash -git add src/screens/ManualsScreen.tsx -git commit -m "feat: add airline add/edit/delete modal" -``` - ---- - -### Task 5: Modal Sezione (add/edit/delete) - -**Files:** -- Modify: `src/screens/ManualsScreen.tsx` - -- [ ] **Step 1: Creare SectionModal** - -Aggiungere dopo `AirlineModal`: - -```tsx -function SectionModal({ - modal, airlines, persist, closeModal, -}: { - modal: ModalState; - airlines: Airline[]; - persist: (a: Airline[]) => void; - closeModal: () => void; -}) { - const { colors } = useAppTheme(); - const isEdit = modal.kind === 'section_edit'; - const airlineId = (modal as any).airlineId as string | undefined; - const sectionIdx = (modal as any).sectionIdx as number | undefined; - const existingTitle = isEdit && airlineId !== undefined && sectionIdx !== undefined - ? airlines.find(a => a.id === airlineId)?.sections[sectionIdx]?.title ?? '' - : ''; - - const [title, setTitle] = useState(existingTitle); - const visible = modal.kind === 'section_add' || modal.kind === 'section_edit'; - - const save = () => { - if (!title.trim() || !airlineId) return; - const updated = airlines.map(a => { - if (a.id !== airlineId) return a; - if (isEdit && sectionIdx !== undefined) { - const sections = a.sections.map((s, i) => - i === sectionIdx ? { ...s, title: title.trim() } : s - ); - return { ...a, sections }; - } else { - return { ...a, sections: [...a.sections, { title: title.trim(), items: [] }] }; - } - }); - persist(updated); - closeModal(); - }; - - const del = () => { - const airline = airlines.find(a => a.id === airlineId); - const sectionTitle = sectionIdx !== undefined ? airline?.sections[sectionIdx]?.title : ''; - Alert.alert( - 'Elimina sezione', - `Eliminare la sezione "${sectionTitle}" e tutte le sue voci?`, - [ - { text: 'Annulla', style: 'cancel' }, - { - text: 'Elimina', style: 'destructive', - onPress: () => { - const updated = airlines.map(a => { - if (a.id !== airlineId) return a; - return { ...a, sections: a.sections.filter((_, i) => i !== sectionIdx) }; - }); - persist(updated); - closeModal(); - }, - }, - ] - ); - }; - - return ( - - - - - {isEdit ? 'Modifica sezione' : 'Nuova sezione'} - - Titolo - - - {isEdit && ( - - Elimina - - )} - - Annulla - - - Salva - - - - - - ); -} -``` - -- [ ] **Step 2: Modificare SectionBlock per ricevere editMode e callback** - -Aggiornare la firma di `SectionBlock`: - -```tsx -function SectionBlock({ - section, sectionIdx, airlineId, editMode, onEdit, -}: { - section: Section; - sectionIdx: number; - airlineId: string; - editMode: boolean; - onEdit: () => void; -}) { -``` - -Nell'header della sezione, aggiungere l'icona ✏️ accanto al titolo: - -```tsx - - {section.title} - - {editMode && ( - - - - )} - - - -``` - -- [ ] **Step 3: Aggiornare le chiamate a SectionBlock nel JSX** - -Nel componente `ManualsScreen`, sostituire il map delle sezioni: - -```tsx -{airline.sections.map((section, i) => ( - setModal({ kind: 'section_edit', airlineId: airline.id, sectionIdx: i })} - /> -))} -{editMode && ( - setModal({ kind: 'section_add', airlineId: airline.id })} - > - - Aggiungi sezione - -)} -``` - -- [ ] **Step 4: Aggiungere stili `addBtn` e `addBtnText` a `makeStyles`** - -Dentro `makeStyles`, aggiungere: - -```typescript -addBtn: { - flexDirection: 'row', alignItems: 'center', gap: 6, - paddingVertical: 10, paddingHorizontal: 12, - borderWidth: 1, borderStyle: 'dashed', borderRadius: 8, - marginBottom: 8, -}, -addBtnText: { fontSize: 13, fontWeight: '600' }, -``` - -- [ ] **Step 5: Aggiungere SectionModal nel render di ManualsScreen condizionalmente** - -```tsx -{(modal.kind === 'section_add' || modal.kind === 'section_edit') && ( - -)} -``` - -- [ ] **Step 6: Testare add e edit sezione** - -Ricaricare. In edit mode: -- `+ Aggiungi sezione` → modal → Salva → la sezione appare. -- Icona ✏️ sulla sezione → modal modifica → Salva / Elimina. - -- [ ] **Step 7: Commit** - -```bash -git add src/screens/ManualsScreen.tsx -git commit -m "feat: add section add/edit/delete modal" -``` - ---- - -### Task 6: Modal Voce (add/edit/delete) - -**Files:** -- Modify: `src/screens/ManualsScreen.tsx` - -- [ ] **Step 1: Creare ItemModal** - -Aggiungere dopo `SectionModal`: - -```tsx -function ItemModal({ - modal, airlines, persist, closeModal, -}: { - modal: ModalState; - airlines: Airline[]; - persist: (a: Airline[]) => void; - closeModal: () => void; -}) { - const { colors } = useAppTheme(); - const isEdit = modal.kind === 'item_edit'; - const airlineId = (modal as any).airlineId as string | undefined; - const sectionIdx = (modal as any).sectionIdx as number | undefined; - const itemIdx = (modal as any).itemIdx as number | undefined; - - const existing = isEdit && airlineId && sectionIdx !== undefined && itemIdx !== undefined - ? airlines.find(a => a.id === airlineId)?.sections[sectionIdx]?.items[itemIdx] - : undefined; - - const [title, setTitle] = useState(existing?.title ?? ''); - const [body, setBody] = useState(existing?.body ?? ''); - const visible = modal.kind === 'item_add' || modal.kind === 'item_edit'; - - const save = () => { - if (!title.trim() || !airlineId || sectionIdx === undefined) return; - const updated = airlines.map(a => { - if (a.id !== airlineId) return a; - const sections = a.sections.map((s, si) => { - if (si !== sectionIdx) return s; - if (isEdit && itemIdx !== undefined) { - const items = s.items.map((it, ii) => - ii === itemIdx ? { title: title.trim(), body: body.trim() } : it - ); - return { ...s, items }; - } else { - return { ...s, items: [...s.items, { title: title.trim(), body: body.trim() }] }; - } - }); - return { ...a, sections }; - }); - persist(updated); - closeModal(); - }; - - const del = () => { - Alert.alert( - 'Elimina voce', - `Eliminare "${existing?.title}"?`, - [ - { text: 'Annulla', style: 'cancel' }, - { - text: 'Elimina', style: 'destructive', - onPress: () => { - const updated = airlines.map(a => { - if (a.id !== airlineId) return a; - const sections = a.sections.map((s, si) => { - if (si !== sectionIdx) return s; - return { ...s, items: s.items.filter((_, ii) => ii !== itemIdx) }; - }); - return { ...a, sections }; - }); - persist(updated); - closeModal(); - }, - }, - ] - ); - }; - - return ( - - - - - {isEdit ? 'Modifica voce' : 'Nuova voce'} - - Titolo - - Contenuto - - - {isEdit && ( - - Elimina - - )} - - Annulla - - - Salva - - - - - - ); -} -``` - -- [ ] **Step 2: Aggiungere `inputMulti` a `modalStyles`** - -Dentro `modalStyles`, aggiungere: - -```typescript -inputMulti: { minHeight: 100, paddingTop: 9 }, -``` - -- [ ] **Step 3: Aggiornare ManualItemRow per supportare edit mode** - -Aggiornare la firma di `ManualItemRow`: - -```tsx -function ManualItemRow({ - item, itemIdx, sectionIdx, airlineId, editMode, onEdit, -}: { - item: ManualItem; - itemIdx: number; - sectionIdx: number; - airlineId: string; - editMode: boolean; - onEdit: () => void; -}) { -``` - -Nell'header della riga, aggiungere l'icona ✏️: - -```tsx - - - {item.title} - {editMode && ( - - - - )} - -``` - -- [ ] **Step 4: Aggiornare SectionBlock per passare editMode e onEdit agli item** - -Nel render degli item dentro `SectionBlock`, sostituire: - -```tsx -{section.items.map((item, i) => ( - -))} -``` - -con: - -Aggiornare le props di `SectionBlock` per includere le callback item: - -```tsx -function SectionBlock({ - section, sectionIdx, airlineId, editMode, onEdit, onAddItem, onEditItem, -}: { - section: Section; - sectionIdx: number; - airlineId: string; - editMode: boolean; - onEdit: () => void; - onAddItem: () => void; - onEditItem: (itemIdx: number) => void; -}) { -``` - -Nel render degli item dentro `SectionBlock`: - -```tsx -{section.items.map((item, i) => ( - onEditItem(i)} - /> -))} -{editMode && ( - - - Aggiungi voce - -)} -``` - -- [ ] **Step 5: Aggiornare il map delle sezioni in ManualsScreen** - -```tsx -{airline.sections.map((section, i) => ( - setModal({ kind: 'section_edit', airlineId: airline.id, sectionIdx: i })} - onAddItem={() => setModal({ kind: 'item_add', airlineId: airline.id, sectionIdx: i })} - onEditItem={(itemIdx) => setModal({ kind: 'item_edit', airlineId: airline.id, sectionIdx: i, itemIdx })} - /> -))} -``` - -- [ ] **Step 6: Aggiungere ItemModal nel render di ManualsScreen condizionalmente** - -```tsx -{(modal.kind === 'item_add' || modal.kind === 'item_edit') && ( - -)} -``` - -- [ ] **Step 7: Testare il flusso completo** - -Ricaricare. In edit mode: -- `+ Aggiungi voce` sotto una sezione → modal → Salva → la voce appare. -- Icona ✏️ su una voce → modal modifica → modifica testo → Salva. -- Elimina con conferma. -- Uscire dall'app e riaprirla: tutte le modifiche persistono. - -- [ ] **Step 8: Commit finale** - -```bash -git add src/screens/ManualsScreen.tsx -git commit -m "feat: add item add/edit/delete modal — manuals CRUD complete" -``` diff --git a/docs/superpowers/plans/2026-03-28-pin-flight.md b/docs/superpowers/plans/2026-03-28-pin-flight.md deleted file mode 100644 index 07b5a65..0000000 --- a/docs/superpowers/plans/2026-03-28-pin-flight.md +++ /dev/null @@ -1,602 +0,0 @@ -# Pin Flight Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Let the user swipe-left on a flight card to pin it, showing it on HomeScreen and receiving step-by-step operational notifications. - -**Architecture:** Pin state stored in AsyncStorage (`pinned_flight_v1`). FlightScreen adds swipe-to-reveal via Animated+PanResponder on each card. HomeScreen reads the pin on mount and renders a gold-bordered card at the top. Pinned notification IDs stored separately (`pinned_notif_ids_v1`) so they can be cancelled independently. - -**Tech Stack:** React Native (Animated, PanResponder), AsyncStorage, expo-notifications, existing `getAirlineOps`/`getAirlineColor` utils. - ---- - -### Task 1: Pin/Unpin Storage & Notification Helpers (FlightScreen) - -**Files:** -- Modify: `src/screens/FlightScreen.tsx:13-14` (add constants) -- Modify: `src/screens/FlightScreen.tsx` (add helper functions after existing notification helpers) - -- [ ] **Step 1: Add storage constants and pinned state** - -At the top of `FlightScreen.tsx`, after the existing `NOTIF_ENABLED_KEY` constant (line 14), add: - -```tsx -const PINNED_FLIGHT_KEY = 'pinned_flight_v1'; -const PINNED_NOTIF_IDS_KEY = 'pinned_notif_ids_v1'; -``` - -Inside the `FlightScreen` component, after the existing state declarations (around line 124), add: - -```tsx -const [pinnedFlightId, setPinnedFlightId] = useState(null); -``` - -- [ ] **Step 2: Load pinned flight ID on mount** - -Inside the existing `fetchAll` function, after `setRefreshing(false)` in the `finally` block (around line 172), this won't work cleanly. Instead, add a separate `useEffect` after the existing ones (after line 175): - -```tsx -useEffect(() => { - AsyncStorage.getItem(PINNED_FLIGHT_KEY).then(raw => { - if (!raw) return; - try { - const pinned = JSON.parse(raw); - const id = pinned.flight?.identification?.id; - if (id) setPinnedFlightId(id); - } catch {} - }); -}, []); -``` - -- [ ] **Step 3: Add schedulePinnedNotifications helper** - -After the existing `scheduleShiftNotifications` function (around line 110), add: - -```tsx -async function cancelPinnedNotifications() { - const raw = await AsyncStorage.getItem(PINNED_NOTIF_IDS_KEY); - if (!raw) return; - const ids: string[] = JSON.parse(raw); - await Promise.all(ids.map(id => Notifications.cancelScheduledNotificationAsync(id).catch(() => {}))); - await AsyncStorage.removeItem(PINNED_NOTIF_IDS_KEY); -} - -async function schedulePinnedNotifications(item: any, tab: 'arrivals' | 'departures'): Promise { - await cancelPinnedNotifications(); - const now = Date.now() / 1000; - const ids: string[] = []; - - const flightNumber = item.flight?.identification?.number?.default || 'N/A'; - const airline = item.flight?.airline?.name || 'Sconosciuta'; - - if (tab === 'arrivals') { - const ts = item.flight?.time?.scheduled?.arrival; - if (!ts) return; - const origin = item.flight?.airport?.origin?.name || item.flight?.airport?.origin?.code?.iata || 'N/A'; - const arrTime = new Date(ts * 1000).toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }); - const secsUntil = ts - 15 * 60 - now; - if (secsUntil > 0) { - const id = await Notifications.scheduleNotificationAsync({ - content: { - title: `📌 Arrivo tra 15 min — ${flightNumber}`, - body: `${airline} da ${origin} · atterraggio alle ${arrTime}`, - sound: true, - data: { flightNumber, ts, pinned: true }, - }, - trigger: { type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL, seconds: Math.round(secsUntil), repeats: false }, - }); - ids.push(id); - } - } else { - const ts = item.flight?.time?.scheduled?.departure; - if (!ts) return; - const dest = item.flight?.airport?.destination?.name || item.flight?.airport?.destination?.code?.iata || 'N/A'; - const depTime = new Date(ts * 1000).toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }); - const ops = getAirlineOps(airline); - - const phases: Array<{ offset: number; title: string; body: string }> = [ - { offset: ops.checkInOpen, title: `📌 Check-in aperto — ${flightNumber}`, body: `Check-in aperto per il volo delle ${depTime} → ${dest}` }, - { offset: ops.gateOpen, title: `📌 Gate aperto — ${flightNumber}`, body: `Gate aperto per il volo delle ${depTime} → ${dest}` }, - { offset: ops.gateClose, title: `📌 Chiusura gate — ${flightNumber}`, body: `Gate in chiusura per il volo delle ${depTime} → ${dest}` }, - { offset: 10, title: `📌 Partenza tra 10 min — ${flightNumber}`, body: `${airline} → ${dest} · partenza alle ${depTime}` }, - ]; - - for (const phase of phases) { - const secsUntil = ts - phase.offset * 60 - now; - if (secsUntil <= 0) continue; - const id = await Notifications.scheduleNotificationAsync({ - content: { - title: phase.title, - body: phase.body, - sound: true, - data: { flightNumber, ts, pinned: true }, - }, - trigger: { type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL, seconds: Math.round(secsUntil), repeats: false }, - }); - ids.push(id); - } - } - - if (ids.length > 0) { - await AsyncStorage.setItem(PINNED_NOTIF_IDS_KEY, JSON.stringify(ids)); - } -} -``` - -- [ ] **Step 4: Add pinFlight and unpinFlight handlers** - -Inside the `FlightScreen` component, after the `toggleNotifications` callback (around line 213), add: - -```tsx -const pinFlight = useCallback(async (item: any) => { - const id = item.flight?.identification?.id; - if (!id) return; - const tab = activeTab; - await AsyncStorage.setItem(PINNED_FLIGHT_KEY, JSON.stringify({ ...item, _pinTab: tab, _pinnedAt: Date.now() })); - setPinnedFlightId(id); - await schedulePinnedNotifications(item, tab); - Alert.alert('Volo pinnato', `${item.flight?.identification?.number?.default || 'Volo'} è ora il tuo volo pinnato.`); -}, [activeTab]); - -const unpinFlight = useCallback(async () => { - await AsyncStorage.removeItem(PINNED_FLIGHT_KEY); - await cancelPinnedNotifications(); - setPinnedFlightId(null); -}, []); -``` - -- [ ] **Step 5: Commit** - -```bash -git add src/screens/FlightScreen.tsx -git commit -m "feat(pin): add pin/unpin storage and notification helpers" -``` - ---- - -### Task 2: Swipe-to-Pin on Flight Cards (FlightScreen) - -**Files:** -- Modify: `src/screens/FlightScreen.tsx` (import `Animated`, `PanResponder`; wrap card in swipeable) - -- [ ] **Step 1: Add Animated to imports** - -Update the React Native import at line 2-5 to include `Animated` and `PanResponder` (if not already imported — `Animated` is not currently imported in FlightScreen): - -```tsx -import { - View, Text, StyleSheet, ActivityIndicator, - FlatList, TouchableOpacity, RefreshControl, Image, Alert, - Animated, PanResponder, -} from 'react-native'; -``` - -- [ ] **Step 2: Create SwipeableFlightCard wrapper component** - -Before the `FlightScreen` component (after the `LogoPill` component), add a new component that wraps a flight card with swipe-to-reveal: - -```tsx -const SWIPE_THRESHOLD = -60; -const PIN_ACTION_WIDTH = 80; - -function SwipeableFlightCard({ - children, - isPinned, - onPin, - onUnpin, -}: { - children: React.ReactNode; - isPinned: boolean; - onPin: () => void; - onUnpin: () => void; -}) { - const translateX = React.useRef(new Animated.Value(0)).current; - const isOpen = React.useRef(false); - - const panResponder = React.useRef( - PanResponder.create({ - onMoveShouldSetPanResponder: (_, g) => Math.abs(g.dx) > 10 && Math.abs(g.dx) > Math.abs(g.dy), - onPanResponderMove: (_, g) => { - if (g.dx < 0) translateX.setValue(Math.max(g.dx, -PIN_ACTION_WIDTH)); - }, - onPanResponderRelease: (_, g) => { - if (g.dx < SWIPE_THRESHOLD) { - Animated.spring(translateX, { toValue: -PIN_ACTION_WIDTH, useNativeDriver: true }).start(); - isOpen.current = true; - } else { - Animated.spring(translateX, { toValue: 0, useNativeDriver: true }).start(); - isOpen.current = false; - } - }, - }) - ).current; - - const close = () => { - Animated.spring(translateX, { toValue: 0, useNativeDriver: true }).start(); - isOpen.current = false; - }; - - return ( - - {/* Action behind the card */} - { - if (isPinned) onUnpin(); else onPin(); - close(); - }} - activeOpacity={0.8} - > - {isPinned ? '📌' : '📌'} - - {isPinned ? 'UNPIN' : 'PIN'} - - - - {/* Sliding card */} - - {children} - - - ); -} -``` - -- [ ] **Step 3: Wrap the flight card in SwipeableFlightCard** - -In `renderFlight`, replace the return statement. Change the current return (line 249-293): - -From: -```tsx - return ( - - {duringShift && ...} - ... - - ); -``` - -To: -```tsx - const flightId = item.flight?.identification?.id; - const isPinned = flightId === pinnedFlightId; - - return ( - pinFlight(item)} - onUnpin={unpinFlight} - > - - {duringShift && DURANTE IL TUO TURNO} - {isPinned && 📌 PINNATO} - {/* Header */} - - - - - {flightNumber} - {airline} - - - - {time} - {originDest} - - - {/* Body */} - - {activeTab === 'departures' && ops ? ( - - - - - Check-in - {fmt(ops.checkInOpen)} – {fmt(ops.checkInClose)} - - - - - - Gate - {fmt(ops.gateOpen)} – {fmt(ops.gateClose)} - - - - ) : ( - {`Da: ${originDest}`} - )} - - {statusText} - - - - - ); -``` - -- [ ] **Step 4: Update renderFlight dependencies** - -The `useCallback` dependency array for `renderFlight` needs `pinnedFlightId`, `pinFlight`, and `unpinFlight`: - -```tsx - }, [activeTab, userShift, s, pinnedFlightId, pinFlight, unpinFlight]); -``` - -- [ ] **Step 5: Add pin styles** - -In the `makeStyles` function, add after `shiftBannerText` (line 379): - -```tsx - cardPinned: { borderWidth: 2, borderColor: '#F59E0B' }, - pinBanner: { backgroundColor: '#F59E0B', paddingVertical: 5, paddingHorizontal: 12 }, - pinBannerText: { color: '#fff', fontWeight: 'bold', fontSize: 11, letterSpacing: 0.5 }, -``` - -- [ ] **Step 6: Remove marginBottom from card style** - -The `SwipeableFlightCard` now handles `marginBottom: 10`, so the original card style should not double it. However, since `s.card` is also used for the card's visual styling, the cleanest approach is to override it inline as shown in Step 3 (`{ marginBottom: 0 }`). - -- [ ] **Step 7: Commit** - -```bash -git add src/screens/FlightScreen.tsx -git commit -m "feat(pin): add swipe-to-pin interaction on flight cards" -``` - ---- - -### Task 3: Pinned Flight Card on HomeScreen - -**Files:** -- Modify: `src/screens/HomeScreen.tsx` (add pinned flight card, imports, state) - -- [ ] **Step 1: Add imports** - -Add to the existing imports in HomeScreen: - -```tsx -import { getAirlineOps, getAirlineColor } from '../utils/airlineOps'; -``` - -Also add the constant: - -```tsx -const PINNED_FLIGHT_KEY = 'pinned_flight_v1'; -``` - -- [ ] **Step 2: Add pinned flight state and loading** - -Inside the `HomeScreen` component, after the existing state declarations, add: - -```tsx -const [pinnedFlight, setPinnedFlight] = useState(null); -``` - -Add a `useEffect` to load the pinned flight (near the other `useEffect` hooks): - -```tsx -useEffect(() => { - const loadPinned = async () => { - const raw = await AsyncStorage.getItem(PINNED_FLIGHT_KEY); - if (!raw) { setPinnedFlight(null); return; } - try { - const pinned = JSON.parse(raw); - const tab = pinned._pinTab || 'departures'; - const ts = tab === 'arrivals' - ? pinned.flight?.time?.scheduled?.arrival - : pinned.flight?.time?.scheduled?.departure; - // Auto-clear if flight is in the past - if (ts && ts < Date.now() / 1000) { - await AsyncStorage.removeItem(PINNED_FLIGHT_KEY); - setPinnedFlight(null); - } else { - setPinnedFlight(pinned); - } - } catch { setPinnedFlight(null); } - }; - loadPinned(); - - // Re-check when screen comes back into focus - const interval = setInterval(loadPinned, 30000); - return () => clearInterval(interval); -}, []); -``` - -- [ ] **Step 3: Add the PinnedFlightCard component** - -Before the `HomeScreen` component, add: - -```tsx -function PinnedFlightCard({ item, colors }: { item: any; colors: any }) { - const tab = item._pinTab || 'departures'; - const flightNumber = item.flight?.identification?.number?.default || 'N/A'; - const airline = item.flight?.airline?.name || 'Sconosciuta'; - const airlineColor = getAirlineColor(airline); - const statusText = item.flight?.status?.text || 'Scheduled'; - const raw = item.flight?.status?.generic?.status?.color || 'gray'; - const statusColor = raw === 'green' ? '#10b981' : raw === 'red' ? '#ef4444' : raw === 'yellow' ? '#f59e0b' : '#6b7280'; - - const dest = tab === 'arrivals' - ? (item.flight?.airport?.origin?.code?.iata || 'N/A') - : (item.flight?.airport?.destination?.code?.iata || 'N/A'); - const ts = tab === 'arrivals' - ? item.flight?.time?.scheduled?.arrival - : item.flight?.time?.scheduled?.departure; - const depTime = ts ? new Date(ts * 1000).toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }) : 'N/A'; - - const ops = getAirlineOps(airline); - const fmt = (offsetMin: number) => - ts ? new Date((ts - offsetMin * 60) * 1000).toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }) : ''; - - return ( - - {/* Gold banner */} - - 📌 VOLO PINNATO - {tab === 'arrivals' ? 'ARRIVO' : 'PARTENZA'} - - - {/* Header with airline color */} - - - {flightNumber} - {airline} - - - {depTime} - → {dest} - - - - {/* Body with ops times (departures) or origin (arrivals) */} - - {tab === 'departures' ? ( - - - - - Check-in - {fmt(ops.checkInOpen)} – {fmt(ops.checkInClose)} - - - - - - Gate - {fmt(ops.gateOpen)} – {fmt(ops.gateClose)} - - - - ) : ( - - Da: {item.flight?.airport?.origin?.name || item.flight?.airport?.origin?.code?.iata || 'N/A'} - - )} - - - {statusText} - - - - - ); -} -``` - -- [ ] **Step 4: Render the pinned card in the JSX** - -In the HomeScreen's return JSX, after the closing `` of the top row (weather + date, around line 362), add: - -```tsx - {/* Pinned flight */} - {pinnedFlight && } -``` - -- [ ] **Step 5: Add MaterialIcons import if not present** - -Check that `MaterialIcons` is already imported (it is — line 12 of HomeScreen). No action needed. - -- [ ] **Step 6: Commit** - -```bash -git add src/screens/HomeScreen.tsx -git commit -m "feat(pin): show pinned flight card on HomeScreen" -``` - ---- - -### Task 4: Auto-Clear Expired Pin - -**Files:** -- Modify: `src/screens/FlightScreen.tsx` (clear stale pin on data refresh) - -- [ ] **Step 1: Add auto-clear in fetchAll** - -Inside the `fetchAll` function, after `setDepartures(fetchedDepartures)` (line 141), add: - -```tsx - // Auto-clear expired pinned flight - const pinnedRaw = await AsyncStorage.getItem(PINNED_FLIGHT_KEY); - if (pinnedRaw) { - try { - const pinned = JSON.parse(pinnedRaw); - const pinTab = pinned._pinTab || 'departures'; - const pinTs = pinTab === 'arrivals' - ? pinned.flight?.time?.scheduled?.arrival - : pinned.flight?.time?.scheduled?.departure; - if (pinTs && pinTs < Date.now() / 1000) { - await AsyncStorage.removeItem(PINNED_FLIGHT_KEY); - await cancelPinnedNotifications(); - setPinnedFlightId(null); - } - } catch {} - } -``` - -- [ ] **Step 2: Commit** - -```bash -git add src/screens/FlightScreen.tsx -git commit -m "feat(pin): auto-clear expired pinned flight on refresh" -``` - ---- - -### Task 5: Manual Testing & Verification - -- [ ] **Step 1: Start the dev server** - -```bash -npx expo start -``` - -- [ ] **Step 2: Test swipe-to-pin** - -1. Open FlightScreen -2. Swipe left on any flight card — gold PIN button should appear -3. Tap PIN — alert should confirm the flight is pinned -4. The card should show a gold "📌 PINNATO" banner -5. Swipe on the same card — red UNPIN button should appear -6. Swipe on a different card and pin it — the previous pin should be replaced - -- [ ] **Step 3: Test HomeScreen card** - -1. Pin a flight from FlightScreen -2. Navigate to HomeScreen -3. A gold-bordered card with flight details should appear below the weather section -4. If departure, check-in and gate times should be shown - -- [ ] **Step 4: Test auto-clear** - -1. Pin a flight that has already departed -2. Pull-to-refresh on FlightScreen -3. The pin should be automatically cleared - -- [ ] **Step 5: Test notifications** - -1. Pin a departure flight -2. Check that notification permissions are granted -3. Verify scheduled notifications cover: check-in open, gate open, gate close, departure -10 min -4. Unpin and verify notifications are cancelled - -- [ ] **Step 6: Final commit** - -```bash -git add -A -git commit -m "feat(pin): complete pin flight feature" -``` diff --git a/docs/superpowers/plans/2026-03-28-wearos-companion.md b/docs/superpowers/plans/2026-03-28-wearos-companion.md deleted file mode 100644 index 552b102..0000000 --- a/docs/superpowers/plans/2026-03-28-wearos-companion.md +++ /dev/null @@ -1,1138 +0,0 @@ -# WearOS Companion App — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build a WearOS companion app that displays pinned flight operational times from the phone app, with a watch face complication showing countdown to the next event. - -**Architecture:** New `wear/` Gradle module with Jetpack Compose for Wear OS (Kotlin). Phone app sends pinned flight data via Wearable Data Layer API (DataClient). Watch listens for changes and renders a timeline UI. Complication provides at-a-glance countdown on the watch face. - -**Tech Stack:** Kotlin 2.1.20, Jetpack Compose for Wear OS, Play Services Wearable (Data Layer API), compileSdk 36, Wear OS minSdk 30, Horologist Compose Layout. - -**Existing project:** React Native (Expo) app with Android module at `android/`. Phone app namespace: `com.anonymous.FlightWorkApp`. Pinned flight stored in AsyncStorage key `pinned_flight_v1`. - ---- - -### Task 1: Create Wear Module Scaffold - -**Files:** -- Create: `android/wear/build.gradle` -- Create: `android/wear/src/main/AndroidManifest.xml` -- Create: `android/wear/src/main/java/com/anonymous/flightworkapp/wear/MainActivity.kt` -- Create: `android/wear/src/main/java/com/anonymous/flightworkapp/wear/theme/Theme.kt` -- Create: `android/wear/src/main/res/values/strings.xml` -- Create: `android/wear/src/main/res/mipmap-hdpi/ic_launcher.png` (copy from app icon) -- Modify: `android/settings.gradle` -- Modify: `android/build.gradle` - -- [ ] **Step 1: Create `android/wear/build.gradle`** - -```groovy -plugins { - id 'com.android.application' - id 'org.jetbrains.kotlin.android' - id 'org.jetbrains.kotlin.plugin.compose' -} - -android { - namespace 'com.anonymous.flightworkapp.wear' - compileSdk 36 - - defaultConfig { - applicationId 'com.anonymous.FlightWorkApp' - minSdkVersion 30 - targetSdkVersion 36 - versionCode 1 - versionName "1.0" - } - - buildFeatures { - compose true - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = '17' - } -} - -dependencies { - implementation platform('androidx.compose:compose-bom:2025.01.01') - implementation 'androidx.wear.compose:compose-material3:1.0.0-alpha32' - implementation 'androidx.wear.compose:compose-foundation:1.5.0' - implementation 'androidx.wear.compose:compose-navigation:1.5.0' - implementation 'androidx.activity:activity-compose:1.9.3' - implementation 'com.google.android.gms:play-services-wearable:19.0.0' - implementation 'androidx.wear.watchface:watchface-complications-data-source-ktx:1.2.1' - implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.7' - implementation 'com.google.android.horologist:horologist-compose-layout:0.6.22' -} -``` - -- [ ] **Step 2: Create `android/wear/src/main/AndroidManifest.xml`** - -```xml - - - - - - - - - - - - - - - - - - - -``` - -- [ ] **Step 3: Create `android/wear/src/main/res/values/strings.xml`** - -```xml - - - AeroStaff - -``` - -- [ ] **Step 4: Create theme file `android/wear/src/main/java/com/anonymous/flightworkapp/wear/theme/Theme.kt`** - -```kotlin -package com.anonymous.flightworkapp.wear.theme - -import androidx.compose.ui.graphics.Color - -object WearColors { - val background = Color(0xFF0F172A) - val textPrimary = Color(0xFFF1F5F9) - val textSecondary = Color(0xFF94A3B8) - val textMuted = Color(0xFF6B7280) - val accent = Color(0xFF3B82F6) - val accentLight = Color(0xFF93C5FD) - val accentBg = Color(0xFF1E3A5F) - val amber = Color(0xFFF59E0B) - val amberBg = Color(0xFF451A03) - val green = Color(0xFF10B981) - val red = Color(0xFFEF4444) - val lineBg = Color(0xFF334155) -} -``` - -- [ ] **Step 5: Create placeholder `MainActivity.kt`** - -```kotlin -package com.anonymous.flightworkapp.wear - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.sp -import androidx.wear.compose.material3.Text -import com.anonymous.flightworkapp.wear.theme.WearColors - -class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContent { - Box( - modifier = Modifier - .fillMaxSize() - .background(WearColors.background), - contentAlignment = Alignment.Center - ) { - Text( - text = "✈️\nNessun volo\nPinna un volo dall'app", - color = WearColors.textSecondary, - fontSize = 14.sp, - textAlign = TextAlign.Center - ) - } - } - } -} -``` - -- [ ] **Step 6: Add wear module to `android/settings.gradle`** - -Add this line before the final `includeBuild` line: - -```groovy -include ':wear' -``` - -- [ ] **Step 7: Copy app icon to wear module** - -```bash -mkdir -p android/wear/src/main/res/mipmap-hdpi -cp android/app/src/main/res/mipmap-hdpi/ic_launcher.png android/wear/src/main/res/mipmap-hdpi/ic_launcher.png -``` - -- [ ] **Step 8: Build wear module to verify scaffold** - -```bash -cd android && ./gradlew :wear:assembleDebug -``` - -Expected: BUILD SUCCESSFUL - -- [ ] **Step 9: Commit** - -```bash -git add android/wear/ android/settings.gradle -git commit -m "feat(wear): scaffold WearOS companion module" -``` - ---- - -### Task 2: Flight Data Model and DataLayer Receiver - -**Files:** -- Create: `android/wear/src/main/java/com/anonymous/flightworkapp/wear/data/FlightData.kt` -- Create: `android/wear/src/main/java/com/anonymous/flightworkapp/wear/data/DataLayerListenerService.kt` -- Modify: `android/wear/src/main/AndroidManifest.xml` - -- [ ] **Step 1: Create `FlightData.kt`** - -```kotlin -package com.anonymous.flightworkapp.wear.data - -import org.json.JSONObject - -data class FlightOps( - val checkInOpen: Int, - val checkInClose: Int, - val gateOpen: Int, - val gateClose: Int -) - -data class FlightData( - val flightNumber: String, - val airline: String, - val airlineColor: String, - val iataCode: String, - val tab: String, // "departures" or "arrivals" - val destination: String, - val origin: String, - val scheduledTime: Long, // unix seconds - val estimatedTime: Long?, - val realDeparture: Long?, - val realArrival: Long?, - val ops: FlightOps?, - val inboundArrival: Long?, - val pinnedAt: Long -) { - companion object { - fun fromJson(json: String): FlightData? { - return try { - val o = JSONObject(json) - val opsObj = o.optJSONObject("ops") - FlightData( - flightNumber = o.getString("flightNumber"), - airline = o.getString("airline"), - airlineColor = o.getString("airlineColor"), - iataCode = o.optString("iataCode", ""), - tab = o.getString("tab"), - destination = o.optString("destination", ""), - origin = o.optString("origin", ""), - scheduledTime = o.getLong("scheduledTime"), - estimatedTime = o.optLong("estimatedTime").takeIf { o.has("estimatedTime") && !o.isNull("estimatedTime") }, - realDeparture = o.optLong("realDeparture").takeIf { o.has("realDeparture") && !o.isNull("realDeparture") }, - realArrival = o.optLong("realArrival").takeIf { o.has("realArrival") && !o.isNull("realArrival") }, - ops = opsObj?.let { - FlightOps( - checkInOpen = it.getInt("checkInOpen"), - checkInClose = it.getInt("checkInClose"), - gateOpen = it.getInt("gateOpen"), - gateClose = it.getInt("gateClose") - ) - }, - inboundArrival = o.optLong("inboundArrival").takeIf { o.has("inboundArrival") && !o.isNull("inboundArrival") }, - pinnedAt = o.optLong("pinnedAt", System.currentTimeMillis() / 1000) - ) - } catch (e: Exception) { - null - } - } - } -} -``` - -- [ ] **Step 2: Create `DataLayerListenerService.kt`** - -```kotlin -package com.anonymous.flightworkapp.wear.data - -import android.content.Intent -import com.google.android.gms.wearable.DataEventBuffer -import com.google.android.gms.wearable.DataMapItem -import com.google.android.gms.wearable.WearableListenerService - -class DataLayerListenerService : WearableListenerService() { - - companion object { - const val PATH_PINNED_FLIGHT = "/pinned_flight" - const val KEY_FLIGHT_JSON = "flight_json" - const val ACTION_FLIGHT_UPDATED = "com.anonymous.flightworkapp.wear.FLIGHT_UPDATED" - const val EXTRA_FLIGHT_JSON = "flight_json" - } - - override fun onDataChanged(dataEvents: DataEventBuffer) { - for (event in dataEvents) { - val uri = event.dataItem.uri - if (uri.path == PATH_PINNED_FLIGHT) { - val dataMap = DataMapItem.fromDataItem(event.dataItem).dataMap - val json = dataMap.getString(KEY_FLIGHT_JSON) ?: "" - val intent = Intent(ACTION_FLIGHT_UPDATED).apply { - putExtra(EXTRA_FLIGHT_JSON, json) - setPackage(packageName) - } - sendBroadcast(intent) - } - } - } -} -``` - -- [ ] **Step 3: Register service in `AndroidManifest.xml`** - -Add inside ``, after the `` block: - -```xml - - - - - - -``` - -- [ ] **Step 4: Build to verify** - -```bash -cd android && ./gradlew :wear:assembleDebug -``` - -Expected: BUILD SUCCESSFUL - -- [ ] **Step 5: Commit** - -```bash -git add android/wear/src/ -git commit -m "feat(wear): add FlightData model and DataLayer listener service" -``` - ---- - -### Task 3: Timeline UI Components - -**Files:** -- Create: `android/wear/src/main/java/com/anonymous/flightworkapp/wear/ui/TimelineEvent.kt` -- Create: `android/wear/src/main/java/com/anonymous/flightworkapp/wear/ui/FlightTimeline.kt` -- Create: `android/wear/src/main/java/com/anonymous/flightworkapp/wear/ui/EmptyState.kt` - -- [ ] **Step 1: Create `TimelineEvent.kt`** — data class for timeline items - -```kotlin -package com.anonymous.flightworkapp.wear.ui - -import androidx.compose.ui.graphics.Color - -enum class EventStatus { PAST, CURRENT, FUTURE } - -data class TimelineEvent( - val label: String, - val time: String, - val status: EventStatus, - val accentColor: Color -) -``` - -- [ ] **Step 2: Create `FlightTimeline.kt`** — the main composable - -```kotlin -package com.anonymous.flightworkapp.wear.ui - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.wear.compose.material3.Text -import com.anonymous.flightworkapp.wear.data.FlightData -import com.anonymous.flightworkapp.wear.theme.WearColors -import java.text.SimpleDateFormat -import java.util.* - -private val timeFmt = SimpleDateFormat("HH:mm", Locale.getDefault()) -private fun fmtTime(epochSec: Long): String = timeFmt.format(Date(epochSec * 1000)) - -fun buildDepartureEvents(flight: FlightData): List { - val now = System.currentTimeMillis() / 1000 - val dep = flight.scheduledTime - val ops = flight.ops ?: return emptyList() - val gateOpenTime = flight.inboundArrival ?: (dep - ops.gateOpen * 60) - - data class RawEvent(val label: String, val ts: Long, val color: Color) - val raw = listOf( - RawEvent("CI Open", dep - ops.checkInOpen * 60, WearColors.accent), - RawEvent("CI Close", dep - ops.checkInClose * 60, WearColors.accent), - RawEvent("Gate", gateOpenTime, WearColors.accent), - RawEvent("Gate Close", dep - ops.gateClose * 60, WearColors.accent), - RawEvent("DEP", dep, WearColors.accent) - ) - - return raw.mapIndexed { i, ev -> - val nextTs = raw.getOrNull(i + 1)?.ts ?: Long.MAX_VALUE - val status = when { - now >= nextTs -> EventStatus.PAST - now >= ev.ts -> EventStatus.CURRENT - else -> EventStatus.FUTURE - } - TimelineEvent(ev.label, fmtTime(ev.ts), status, ev.color) - } -} - -fun buildArrivalEvents(flight: FlightData): List { - val now = System.currentTimeMillis() / 1000 - val bestArrival = flight.realArrival ?: flight.estimatedTime ?: flight.scheduledTime - val landed = flight.realArrival != null - val departed = flight.realDeparture != null - - val events = mutableListOf() - - // Partito - val depStatus = if (departed) EventStatus.PAST else EventStatus.FUTURE - events.add(TimelineEvent( - "Partito", - if (departed) fmtTime(flight.realDeparture!!) else "--:--", - depStatus, - WearColors.green - )) - - // In volo - if (departed && !landed) { - val remaining = bestArrival - now - val mins = (remaining / 60).coerceAtLeast(0) - val h = mins / 60 - val m = mins % 60 - val timeStr = if (h > 0) "~${h}h ${m}m" else "~${m}m" - events.add(TimelineEvent("In volo", timeStr, EventStatus.CURRENT, WearColors.amber)) - } - - // Atterraggio - val arrStatus = when { - landed -> EventStatus.PAST - departed -> EventStatus.FUTURE - else -> EventStatus.FUTURE - } - events.add(TimelineEvent( - if (landed) "Atterrato" else "Atterraggio", - fmtTime(bestArrival), - arrStatus, - if (landed) WearColors.green else WearColors.accent - )) - - return events -} - -@Composable -fun FlightTimelineScreen(flight: FlightData) { - val headerColor = try { Color(android.graphics.Color.parseColor(flight.airlineColor)) } catch (_: Exception) { WearColors.accent } - val events = if (flight.tab == "departures") buildDepartureEvents(flight) else buildArrivalEvents(flight) - val now = System.currentTimeMillis() / 1000 - - // Countdown text - val currentOrNext = events.firstOrNull { it.status == EventStatus.CURRENT } - ?: events.firstOrNull { it.status == EventStatus.FUTURE } - val countdownText = if (flight.tab == "arrivals") { - val best = flight.realArrival ?: flight.estimatedTime ?: flight.scheduledTime - val delay = ((best - flight.scheduledTime) / 60).toInt() - if (flight.realArrival != null) "Atterrato" - else if (delay > 0) "+$delay min ritardo" - else "In orario" - } else { - currentOrNext?.let { "${it.label} tra ${((events.indexOf(it)).let { idx -> - // Find the timestamp for countdown - val dep = flight.scheduledTime - val ops = flight.ops - if (ops != null) { - val gateOpenTime = flight.inboundArrival ?: (dep - ops.gateOpen * 60) - val timestamps = listOf( - dep - ops.checkInOpen * 60, - dep - ops.checkInClose * 60, - gateOpenTime, - dep - ops.gateClose * 60, - dep - ) - val ts = timestamps.getOrNull(idx) ?: dep - val mins = ((ts - now) / 60).coerceAtLeast(0) - if (mins > 60) "${mins / 60}h ${mins % 60}m" else "${mins}m" - } else "?" - })}" } ?: "" - } - - // Header label - val headerLabel = if (flight.tab == "departures") { - "${flight.flightNumber} → ${flight.destination}" - } else { - "${flight.flightNumber} ← ${flight.origin}" - } - - Column( - modifier = Modifier - .fillMaxSize() - .background(WearColors.background) - ) { - // Header - Box( - modifier = Modifier - .fillMaxWidth() - .background(headerColor) - .padding(vertical = 5.dp, horizontal = 10.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = headerLabel, - color = Color.White, - fontSize = 14.sp, - fontWeight = FontWeight.Black - ) - } - - // Timeline - Column( - modifier = Modifier - .weight(1f) - .padding(start = 24.dp, end = 20.dp, top = 8.dp) - ) { - events.forEachIndexed { index, event -> - Row( - modifier = Modifier.padding(bottom = if (event.status == EventStatus.CURRENT || flight.tab == "arrivals") 8.dp else 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Dot - val dotSize = if (event.status == EventStatus.CURRENT) 12.dp else 8.dp - val dotColor = when (event.status) { - EventStatus.PAST -> WearColors.green - EventStatus.CURRENT -> event.accentColor - EventStatus.FUTURE -> WearColors.lineBg - } - Box( - modifier = Modifier - .size(dotSize) - .clip(CircleShape) - .background(dotColor) - ) - - Spacer(Modifier.width(10.dp)) - - // Label + time - val rowModifier = if (event.status == EventStatus.CURRENT) { - Modifier - .fillMaxWidth() - .background(WearColors.accentBg, RoundedCornerShape(6.dp)) - .padding(horizontal = 6.dp, vertical = 3.dp) - } else { - Modifier.fillMaxWidth() - } - - Row( - modifier = rowModifier, - horizontalArrangement = Arrangement.SpaceBetween - ) { - val textColor = when (event.status) { - EventStatus.PAST -> WearColors.textMuted - EventStatus.CURRENT -> WearColors.accentLight - EventStatus.FUTURE -> WearColors.textSecondary - } - val fontSize = if (event.status == EventStatus.CURRENT) 14.sp else 12.sp - val weight = if (event.status == EventStatus.CURRENT) FontWeight.ExtraBold else FontWeight.Normal - val decoration = if (event.status == EventStatus.PAST) TextDecoration.LineThrough else TextDecoration.None - - Text(event.label, color = textColor, fontSize = fontSize, fontWeight = weight, textDecoration = decoration) - Text(event.time, color = textColor, fontSize = fontSize, fontWeight = weight, textDecoration = decoration) - } - } - } - } - - // Countdown - if (countdownText.isNotEmpty()) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 12.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = countdownText, - color = WearColors.amber, - fontSize = 13.sp, - fontWeight = FontWeight.ExtraBold, - textAlign = TextAlign.Center - ) - } - } - } -} -``` - -- [ ] **Step 3: Create `EmptyState.kt`** - -```kotlin -package com.anonymous.flightworkapp.wear.ui - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.wear.compose.material3.Text -import com.anonymous.flightworkapp.wear.theme.WearColors - -@Composable -fun EmptyState() { - Box( - modifier = Modifier - .fillMaxSize() - .background(WearColors.background), - contentAlignment = Alignment.Center - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text("✈️", fontSize = 36.sp) - Spacer(Modifier.height(8.dp)) - Text( - "Nessun volo", - color = WearColors.textSecondary, - fontSize = 14.sp, - fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold - ) - Spacer(Modifier.height(4.dp)) - Text( - "Pinna un volo dall'app", - color = WearColors.textMuted, - fontSize = 11.sp, - textAlign = TextAlign.Center - ) - } - } -} -``` - -- [ ] **Step 4: Build to verify** - -```bash -cd android && ./gradlew :wear:assembleDebug -``` - -Expected: BUILD SUCCESSFUL - -- [ ] **Step 5: Commit** - -```bash -git add android/wear/src/ -git commit -m "feat(wear): add timeline UI components and empty state" -``` - ---- - -### Task 4: Wire MainActivity with DataLayer - -**Files:** -- Modify: `android/wear/src/main/java/com/anonymous/flightworkapp/wear/MainActivity.kt` - -- [ ] **Step 1: Update `MainActivity.kt` to receive data and show UI** - -```kotlin -package com.anonymous.flightworkapp.wear - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.os.Build -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.compose.runtime.* -import com.anonymous.flightworkapp.wear.data.DataLayerListenerService -import com.anonymous.flightworkapp.wear.data.FlightData -import com.anonymous.flightworkapp.wear.ui.EmptyState -import com.anonymous.flightworkapp.wear.ui.FlightTimelineScreen -import com.google.android.gms.wearable.DataMapItem -import com.google.android.gms.wearable.Wearable -import kotlinx.coroutines.tasks.await - -class MainActivity : ComponentActivity() { - - private var flightState = mutableStateOf(null) - - private val receiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - val json = intent.getStringExtra(DataLayerListenerService.EXTRA_FLIGHT_JSON) ?: "" - flightState.value = if (json.isNotEmpty()) FlightData.fromJson(json) else null - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - // Load initial data from DataLayer - loadInitialData() - - setContent { - val flight by flightState - if (flight != null) { - FlightTimelineScreen(flight!!) - } else { - EmptyState() - } - } - } - - override fun onResume() { - super.onResume() - val filter = IntentFilter(DataLayerListenerService.ACTION_FLIGHT_UPDATED) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED) - } else { - registerReceiver(receiver, filter) - } - loadInitialData() - } - - override fun onPause() { - super.onPause() - unregisterReceiver(receiver) - } - - private fun loadInitialData() { - Wearable.getDataClient(this).getDataItems().addOnSuccessListener { items -> - for (item in items) { - if (item.uri.path == DataLayerListenerService.PATH_PINNED_FLIGHT) { - val dataMap = DataMapItem.fromDataItem(item).dataMap - val json = dataMap.getString(DataLayerListenerService.KEY_FLIGHT_JSON) ?: "" - flightState.value = if (json.isNotEmpty()) FlightData.fromJson(json) else null - } - } - items.release() - } - } -} -``` - -- [ ] **Step 2: Build to verify** - -```bash -cd android && ./gradlew :wear:assembleDebug -``` - -Expected: BUILD SUCCESSFUL - -- [ ] **Step 3: Commit** - -```bash -git add android/wear/src/ -git commit -m "feat(wear): wire MainActivity with DataLayer receiver" -``` - ---- - -### Task 5: Watch Face Complication - -**Files:** -- Create: `android/wear/src/main/java/com/anonymous/flightworkapp/wear/complication/FlightComplicationService.kt` -- Modify: `android/wear/src/main/AndroidManifest.xml` -- Create: `android/wear/src/main/res/drawable/ic_flight.xml` - -- [ ] **Step 1: Create `ic_flight.xml` vector drawable** - -```xml - - - - -``` - -- [ ] **Step 2: Create `FlightComplicationService.kt`** - -```kotlin -package com.anonymous.flightworkapp.wear.complication - -import android.app.PendingIntent -import android.content.Intent -import androidx.wear.watchface.complications.data.* -import androidx.wear.watchface.complications.datasource.ComplicationRequest -import androidx.wear.watchface.complications.datasource.SuspendingComplicationDataSourceService -import com.anonymous.flightworkapp.wear.MainActivity -import com.anonymous.flightworkapp.wear.data.DataLayerListenerService -import com.anonymous.flightworkapp.wear.data.FlightData -import com.google.android.gms.wearable.DataMapItem -import com.google.android.gms.wearable.Wearable -import kotlinx.coroutines.tasks.await - -class FlightComplicationService : SuspendingComplicationDataSourceService() { - - override fun getPreviewData(type: ComplicationType): ComplicationData { - return ShortTextComplicationData.Builder( - text = PlainComplicationText.Builder("Gate 12m").build(), - contentDescription = PlainComplicationText.Builder("Flight countdown").build() - ) - .setMonochromaticImage( - MonochromaticImage.Builder( - SmallImage.Builder( - android.graphics.drawable.Icon.createWithResource(this, com.anonymous.flightworkapp.wear.R.drawable.ic_flight), - SmallImageType.ICON - ).build().image - ).build() - ) - .build() - } - - override suspend fun onComplicationRequest(request: ComplicationRequest): ComplicationData { - val tapIntent = Intent(this, MainActivity::class.java) - val pendingIntent = PendingIntent.getActivity( - this, 0, tapIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - - val flight = loadFlightData() - - val text = if (flight == null) { - "—" - } else { - getCountdownText(flight) - } - - return ShortTextComplicationData.Builder( - text = PlainComplicationText.Builder(text).build(), - contentDescription = PlainComplicationText.Builder("Flight: $text").build() - ) - .setTapAction(pendingIntent) - .build() - } - - private suspend fun loadFlightData(): FlightData? { - return try { - val items = Wearable.getDataClient(this).dataItems.await() - var result: FlightData? = null - for (item in items) { - if (item.uri.path == DataLayerListenerService.PATH_PINNED_FLIGHT) { - val json = DataMapItem.fromDataItem(item).dataMap - .getString(DataLayerListenerService.KEY_FLIGHT_JSON) ?: "" - if (json.isNotEmpty()) result = FlightData.fromJson(json) - } - } - items.release() - result - } catch (_: Exception) { null } - } - - private fun getCountdownText(flight: FlightData): String { - val now = System.currentTimeMillis() / 1000 - - if (flight.tab == "arrivals") { - val best = flight.realArrival ?: flight.estimatedTime ?: flight.scheduledTime - if (flight.realArrival != null) return "Landed" - val mins = ((best - now) / 60).coerceAtLeast(0) - return if (mins > 60) "ETA ${mins / 60}h${mins % 60}m" else "ETA ${mins}m" - } - - // Departures - val dep = flight.scheduledTime - val ops = flight.ops ?: return fmtMins(dep - now) - val gateOpenTime = flight.inboundArrival ?: (dep - ops.gateOpen * 60) - - data class Ev(val label: String, val ts: Long) - val events = listOf( - Ev("CI", dep - ops.checkInOpen * 60), - Ev("CI End", dep - ops.checkInClose * 60), - Ev("Gate", gateOpenTime), - Ev("GClose", dep - ops.gateClose * 60), - Ev("DEP", dep) - ) - - val next = events.firstOrNull { it.ts > now } - return if (next != null) { - "${next.label} ${fmtMins(next.ts - now)}" - } else { - "DEP" - } - } - - private fun fmtMins(secs: Long): String { - val mins = (secs / 60).coerceAtLeast(0) - return if (mins > 60) "${mins / 60}h${mins % 60}m" else "${mins}m" - } -} -``` - -- [ ] **Step 3: Register complication in `AndroidManifest.xml`** - -Add inside ``, after the DataLayerListenerService: - -```xml - - - - - - - -``` - -- [ ] **Step 4: Build to verify** - -```bash -cd android && ./gradlew :wear:assembleDebug -``` - -Expected: BUILD SUCCESSFUL - -- [ ] **Step 5: Commit** - -```bash -git add android/wear/src/ -git commit -m "feat(wear): add watch face complication with flight countdown" -``` - ---- - -### Task 6: Phone App — Send Data to Watch - -**Files:** -- Modify: `android/app/build.gradle` (add wearable dependency) -- Create: `android/app/src/main/java/com/anonymous/FlightWorkApp/WearDataSender.kt` -- Modify: `src/screens/FlightScreen.tsx` (call native module on pin/unpin) - -- [ ] **Step 1: Add wearable dependency to `android/app/build.gradle`** - -In the `dependencies` block, add: - -```groovy -implementation 'com.google.android.gms:play-services-wearable:19.0.0' -``` - -- [ ] **Step 2: Create `WearDataSender.kt`** — React Native native module - -```kotlin -package com.anonymous.FlightWorkApp - -import com.facebook.react.bridge.* -import com.google.android.gms.wearable.PutDataMapRequest -import com.google.android.gms.wearable.Wearable - -class WearDataSender(reactContext: ReactApplicationContext) : - ReactContextBaseJavaModule(reactContext) { - - override fun getName() = "WearDataSender" - - @ReactMethod - fun sendPinnedFlight(jsonString: String) { - val request = PutDataMapRequest.create("/pinned_flight").apply { - dataMap.putString("flight_json", jsonString) - dataMap.putLong("timestamp", System.currentTimeMillis()) - } - val putDataReq = request.asPutDataRequest().setUrgent() - Wearable.getDataClient(reactApplicationContext).putDataItem(putDataReq) - } - - @ReactMethod - fun clearPinnedFlight() { - val request = PutDataMapRequest.create("/pinned_flight").apply { - dataMap.putString("flight_json", "") - dataMap.putLong("timestamp", System.currentTimeMillis()) - } - val putDataReq = request.asPutDataRequest().setUrgent() - Wearable.getDataClient(reactApplicationContext).putDataItem(putDataReq) - } -} -``` - -- [ ] **Step 3: Create `WearDataSenderPackage.kt`** — package registration - -```kotlin -package com.anonymous.FlightWorkApp - -import com.facebook.react.ReactPackage -import com.facebook.react.bridge.NativeModule -import com.facebook.react.bridge.ReactApplicationContext -import com.facebook.react.uimanager.ViewManager - -class WearDataSenderPackage : ReactPackage { - override fun createNativeModules(reactContext: ReactApplicationContext): List { - return listOf(WearDataSender(reactContext)) - } - - override fun createViewManagers(reactContext: ReactApplicationContext): List> { - return emptyList() - } -} -``` - -- [ ] **Step 4: Register the package in `MainApplication.kt`** - -Find `android/app/src/main/java/com/anonymous/FlightWorkApp/MainApplication.kt`. In the `getPackages()` method, add `WearDataSenderPackage()` to the list. The exact location depends on Expo's template — look for `packages.add(...)` or the list returned, and add: - -```kotlin -packages.add(WearDataSenderPackage()) -``` - -- [ ] **Step 5: Update `FlightScreen.tsx` — send data on pin/unpin** - -At the top of the file, add: - -```typescript -import { NativeModules, Platform } from 'react-native'; - -const WearDataSender = Platform.OS === 'android' ? NativeModules.WearDataSender : null; -``` - -In the `pinFlight` callback, after `setPinnedFlightId(id)`, add: - -```typescript - // Send to watch - if (WearDataSender) { - const payload = JSON.stringify({ - flightNumber: item.flight?.identification?.number?.default || '', - airline: item.flight?.airline?.name || '', - airlineColor: getAirlineColor(item.flight?.airline?.name || ''), - iataCode: item.flight?.airline?.code?.iata || '', - tab, - destination: item.flight?.airport?.destination?.name || item.flight?.airport?.destination?.code?.iata || '', - origin: item.flight?.airport?.origin?.name || item.flight?.airport?.origin?.code?.iata || '', - scheduledTime: tab === 'departures' ? item.flight?.time?.scheduled?.departure : item.flight?.time?.scheduled?.arrival, - estimatedTime: tab === 'departures' ? item.flight?.time?.estimated?.departure : item.flight?.time?.estimated?.arrival, - realDeparture: item.flight?.time?.real?.departure || null, - realArrival: item.flight?.time?.real?.arrival || null, - ops: tab === 'departures' ? getAirlineOps(item.flight?.airline?.name || '') : null, - inboundArrival: tab === 'departures' && item.flight?.aircraft?.registration ? inboundArrivals[item.flight.aircraft.registration] || null : null, - pinnedAt: Math.floor(Date.now() / 1000), - }); - WearDataSender.sendPinnedFlight(payload); - } -``` - -In the `unpinFlight` callback, after `setPinnedFlightId(null)`, add: - -```typescript - if (WearDataSender) WearDataSender.clearPinnedFlight(); -``` - -- [ ] **Step 6: Build both modules to verify** - -```bash -cd android && ./gradlew assembleRelease :wear:assembleDebug -``` - -Expected: Both BUILD SUCCESSFUL - -- [ ] **Step 7: Commit** - -```bash -git add android/app/ src/screens/FlightScreen.tsx -git commit -m "feat(wear): add phone-to-watch data sync via native module" -``` - ---- - -### Task 7: Build and Install on Watch - -- [ ] **Step 1: Build wear APK** - -```bash -cd android && ./gradlew :wear:assembleRelease -``` - -Output at: `android/wear/build/outputs/apk/release/wear-release.apk` - -- [ ] **Step 2: Copy to Downloads** - -```bash -cp android/wear/build/outputs/apk/release/wear-release.apk ~/Downloads/AeroStaffWear.apk -``` - -- [ ] **Step 3: Build phone APK** - -```bash -node rename_node_modules_builds.js && cd android && ./gradlew assembleRelease -cp app/build/outputs/apk/release/app-release.apk ~/Downloads/AeroStaffPro.apk -``` - -- [ ] **Step 4: Install both APKs** - -Install phone APK normally. For watch: -```bash -adb -s :5555 install ~/Downloads/AeroStaffWear.apk -``` - -Or transfer via Galaxy Wearable app / Wear Installer. - -- [ ] **Step 5: Test flow** - -1. Open AeroStaff Pro on phone -2. Go to Voli → Partenze -3. Swipe left on a flight to pin it -4. Check watch — should show timeline with ops times -5. Unpin flight — watch should show empty state -6. Add complication to watch face — should show countdown - -- [ ] **Step 6: Commit final** - -```bash -git add -A -git commit -m "feat(wear): WearOS companion app complete" -``` diff --git a/docs/superpowers/specs/2026-03-25-flight-card-rework-design.md b/docs/superpowers/specs/2026-03-25-flight-card-rework-design.md deleted file mode 100644 index d911484..0000000 --- a/docs/superpowers/specs/2026-03-25-flight-card-rework-design.md +++ /dev/null @@ -1,56 +0,0 @@ -# Flight Card Rework — Design Spec - -## Goal -Rework the flight cards in FlightScreen to be visually recognizable by airline, and set Departures as the default tab. - -## Changes - -### 1. Default tab -Change `useState<'arrivals' | 'departures'>('arrivals')` → `('departures')`. - -### 2. Card layout — B2 - -Replace the current card structure (AirlineLogo box + separate opsRow) with a two-section card: - -#### Header (colored, airline gradient) -- Background: `linear-gradient` using airline brand color (same colors as `airlineColors` map) -- **Left side:** logo pill (white semi-opaque bg, 52×32px, rounded) showing airline logo image from avs.io or initials fallback in brand color — then flight number (bold white, 15px) + airline name (white 80% opacity, 10px) -- **Right side:** scheduled time (bold white, 18px) + destination or origin city (white 80% opacity, 10px) - -#### Body (card background) -- **Departures:** `🖥 CI HH:MM–HH:MM · 🚪 Gate HH:MM–HH:MM` (9–10px, textSub color) + status pill aligned right -- **Arrivals:** `Da: [origin city name or IATA]` (10px, textSub) + status pill aligned right -- Single-row layout, padding 8–10px horizontal - -#### Shift banner -Unchanged: `⭐ DURANTE IL TUO TURNO` amber banner rendered **above** the header when `duringShift` is true. - -### 3. Removed -- Old `AirlineLogo` component (replaced by inline logo pill in header) -- Separate `opsRow` / `opsCell` / `opsDivider` / `opsLabel` / `opsTime` / `opsSub` styles (ops info now inline in body) -- Old card styles: `cardRow`, `airlineName`, `flightNum`, `route`, `routeDest`, `time`, `statusPill`, `statusText` - -### 4. Kept -- `AIRLINE_OPS` table and `getAirlineOps()` function (used to compute CI/gate times for body) -- `airlineColors` map and `getAirlineColor()` (used for header gradient) -- `logoStyles` static StyleSheet → replaced by inline styles in header -- Status color logic (`raw === 'green' ? '#10b981' : ...`) - -## Files Changed -| File | Change | -|------|--------| -| `src/screens/FlightScreen.tsx` | Default tab state, reworked `renderFlight`, updated `makeStyles` | - -## New Styles Needed -- `cardHeader`: flexDirection row, justifyContent space-between, alignItems center, padding 10/14px -- `headerLogoPill`: width 52, height 32, borderRadius 8, backgroundColor rgba(255,255,255,0.9), justifyContent/alignItems center, overflow hidden -- `headerLogoImg`: width 44, height 26, resizeMode contain -- `headerLeft`: flexDirection row, alignItems center, gap 10 -- `headerFlightNum`: color #fff, fontWeight 900, fontSize 15 -- `headerAirlineName`: color rgba(255,255,255,0.8), fontSize 10 -- `headerTime`: color #fff, fontWeight 900, fontSize 18 -- `headerDest`: color rgba(255,255,255,0.8), fontSize 10, textAlign right -- `cardBody`: flexDirection row, alignItems center, padding 8/14px, backgroundColor c.card -- `bodyInfo`: flex 1, fontSize 10, color c.textSub -- `statusPill`: paddingHorizontal 8, paddingVertical 3, borderRadius 20 -- `statusText`: fontSize 10, fontWeight 700 diff --git a/docs/superpowers/specs/2026-03-25-flight-ops-times-design.md b/docs/superpowers/specs/2026-03-25-flight-ops-times-design.md deleted file mode 100644 index dd24005..0000000 --- a/docs/superpowers/specs/2026-03-25-flight-ops-times-design.md +++ /dev/null @@ -1,67 +0,0 @@ -# Flight Operational Times — Design Spec - -## Goal -Show check-in and gate opening/closing times on departure flight cards in FlightScreen, calculated from scheduled departure time using per-airline policy tables. - -## Scope -- **Departures tab only** — arrivals tab unchanged -- Applies to all departure cards (not just shift flights) -- Single file change: `src/screens/FlightScreen.tsx` - -## Data Model - -New constant `AIRLINE_OPS` maps airline name keywords to 4 offsets (minutes before departure): - -```typescript -type AirlineOps = { - checkInOpen: number; // minutes before departure - checkInClose: number; - gateOpen: number; - gateClose: number; -}; -``` - -### Policy Table - -| Airline keyword | checkInOpen | checkInClose | gateOpen | gateClose | -|----------------|-------------|--------------|----------|-----------| -| easyjet | 120 | 40 | 30 | 20 | -| wizz | 180 | 40 | 30 | 15 | -| ryanair | 150 | 40 | 30 | 20 | -| aer lingus | 150 | 40 | 30 | 20 | -| british airways| 180 | 45 | 45 | 20 | -| sas / scandinavian | 120 | 40 | 30 | 20 | -| flydubai | 180 | 60 | 40 | 20 | -| default | 120 | 40 | 30 | 20 | - -## Logic - -``` -function getAirlineOps(airlineName: string): AirlineOps -``` -Lowercases the airline name and checks for keyword matches against AIRLINE_OPS. Returns default if no match. - -Times are computed as: -``` -checkInOpenTime = departureTimestamp - (checkInOpen * 60) → formatted HH:MM -checkInCloseTime = departureTimestamp - (checkInClose * 60) -gateOpenTime = departureTimestamp - (gateOpen * 60) -gateCloseTime = departureTimestamp - (gateClose * 60) -``` - -If `ts` (departure timestamp) is undefined, the times row is not rendered. - -## UI - -Layout A (always visible compact row) added to each departure card: -- Thin top border separating it from the main card content -- 4 equal columns: Check-in apre | Check-in chiude | Gate apre | Gate chiude -- Each column: label (9px uppercase), time (11px bold), sublabel (9px "apre"/"chiude") -- Colors: apre → `c.primary` (blue), chiude → `#EF4444` (red), gate apre → `#F59E0B` (amber) -- Only rendered when `activeTab === 'departures'` and `ts` is defined - -## Files Changed - -| File | Change | -|------|--------| -| `src/screens/FlightScreen.tsx` | Add `AIRLINE_OPS` constant, `getAirlineOps()` function, ops times row in `renderFlight`, new styles in `makeStyles` | diff --git a/docs/superpowers/specs/2026-03-26-android-widget-design.md b/docs/superpowers/specs/2026-03-26-android-widget-design.md deleted file mode 100644 index 5269a8c..0000000 --- a/docs/superpowers/specs/2026-03-26-android-widget-design.md +++ /dev/null @@ -1,96 +0,0 @@ -# Widget Android — Lista Voli Turno - -## Panoramica - -Widget Android 4x4 che mostra la lista compatta dei voli durante il turno corrente con orari check-in e gate. Si aggiorna automaticamente ogni 30 minuti. - -## Libreria - -`react-native-android-widget` — permette di definire il layout widget in JSX/TypeScript, funziona con Expo via config plugin. Il widget viene renderizzato nativamente da Android. - -## Layout - -``` -┌─────────────────────────────────────┐ -│ ✈ Turno Lavoro 06:00 – 14:00 │ -│─────────────────────────────────────│ -│ W6 1234 BUD CI 04:00–05:20 G 05:30–05:40 │ -│ EJ 5678 CDG CI 05:00–06:20 G 06:30–06:40 │ -│ BA 902 LHR CI 05:30–06:15 G 06:15–06:40 │ -│ W6 4321 OTP CI 06:00–07:20 G 07:30–07:40 │ -│ ... │ -│─────────────────────────────────────│ -│ Ultimo aggiornamento: 10:30 │ -└─────────────────────────────────────┘ -``` - -### Header -- Icona aereo + "Turno Lavoro" + orario turno (HH:MM – HH:MM) -- Sfondo leggermente più chiaro del body - -### Righe voli -- Ogni riga: numero volo (bold), destinazione IATA, orari CI (arancione `#F59E0B`), orari Gate (blu `#3B82F6`) -- Ordinati per orario partenza -- Se più voli di quelli che entrano: il widget è scrollabile (Android supporta ListView nei widget) - -### Footer -- "Ultimo aggiornamento: HH:MM" in testo piccolo grigio - -### Tap -- Tap su qualsiasi punto del widget: apre l'app sulla HomeScreen - -## Dati - -### Fonte -- Stessa API FlightRadar24: `https://api.flightradar24.com/common/v1/airport.json?code=psa&plugin[]=schedule&page=1&limit=100` -- Calendario di sistema per il turno (stessa logica di HomeScreen) - -### Filtro -- Solo partenze (`schedule.departures.data`) -- Filtro compagnie: `ALLOWED_AIRLINES` da `src/utils/airlineOps.ts` -- Filtro temporale: partenza compresa tra shiftStart e shiftEnd -- Ordinamento: per orario partenza crescente - -### Tempi operativi -- Calcolati con `getAirlineOps()` da `src/utils/airlineOps.ts` -- CI Open = partenza - checkInOpen minuti -- CI Close = partenza - checkInClose minuti -- Gate Open = partenza - gateOpen minuti -- Gate Close = partenza - gateClose minuti - -## Aggiornamento - -- `updatePeriodMillis`: 1800000 (30 minuti, minimo Android) -- Nessun background service, nessun push — Android gestisce il ciclo di aggiornamento -- Al primo posizionamento del widget: fetch immediato - -## Stati - -- **Turno Lavoro con voli**: layout completo con lista voli -- **Turno Lavoro senza voli**: header turno + "Nessuna partenza nel turno" centrato -- **Giorno di Riposo**: icona palma + "Giorno di Riposo" centrato -- **Nessun turno**: "Nessun turno oggi" centrato -- **Errore fetch**: "Aggiornamento fallito" + mostra ultimo dato valido se disponibile - -## Tema - -- Sfondo scuro fisso (`#0F172A`) — funziona su qualsiasi home screen, risparmia batteria OLED -- Testo principale: bianco `#F1F5F9` -- Testo secondario: grigio `#94A3B8` -- CI orari: arancione `#F59E0B` -- Gate orari: blu `#3B82F6` -- Header sfondo: `#1E293B` -- Bordi arrotondati: 16dp - -## Struttura file - -- `src/widgets/ShiftWidget.tsx` — componente widget (JSX per react-native-android-widget) -- `src/widgets/shiftWidgetTask.ts` — task handler che fetcha dati e passa al widget -- Riusa `src/utils/airlineOps.ts` per costanti e funzioni condivise -- Config plugin in `app.json` per registrare il widget - -## Dipendenze - -- `react-native-android-widget` — rendering widget -- `expo-calendar` (già installato) — lettura turni -- Nessuna nuova dipendenza per il fetch (usa fetch nativo) diff --git a/docs/superpowers/specs/2026-03-26-shift-task-timeline-design.md b/docs/superpowers/specs/2026-03-26-shift-task-timeline-design.md deleted file mode 100644 index 35952b0..0000000 --- a/docs/superpowers/specs/2026-03-26-shift-task-timeline-design.md +++ /dev/null @@ -1,66 +0,0 @@ -# Shift Task Timeline — Bottom Sheet con Timeline Voli - -## Panoramica - -Aggiunta di un bottom sheet attivato dal pulsante "Dettagli Task" nella card turno di HomeScreen. Mostra una timeline verticale di tutte le partenze durante il turno, con barre colorate per le finestre operative di check-in e gate. - -## Trigger - -Tap su "Dettagli Task" nella card "Turno Attuale" di HomeScreen (visibile solo quando il turno è di tipo Lavoro). - -## Contenitore - -- Modal bottom sheet, 80% altezza schermo -- Handle di chiusura in alto (barra grigia) -- Titolo: "Voli nel Turno · HH:MM – HH:MM" (orari del turno) -- ScrollView verticale per la timeline - -## Dati - -- **Fonte**: stessa API FlightRadar24 usata in FlightScreen (`https://api.flightradar24.com/common/v1/airport.json?code=psa&plugin[]=schedule&page=1&limit=100`) -- **Filtro tipo**: solo partenze (`plugin-result.schedule.departures.data`) -- **Filtro compagnie**: stesse airline di FlightScreen (wizz, easyjet, british airways, sas, aer lingus, flydubai) -- **Filtro temporale**: `flight.time.scheduled.departure` compreso tra `shiftEvent.startDate` e `shiftEvent.endDate` (Unix timestamps in secondi) -- **Tempi operativi**: calcolati con la stessa mappa `AIRLINE_OPS` di FlightScreen (checkInOpen, checkInClose, gateOpen, gateClose in minuti prima della partenza) - -## Timeline - -- **Asse verticale** = tempo, da startDate a endDate del turno -- **Indicatori orari**: tacche ogni 30 minuti sul lato sinistro con label "HH:MM" -- **Linea "adesso"**: linea rossa tratteggiata orizzontale, visibile solo se il turno è in corso (`Date.now()` tra start e end) -- **Ogni volo** è una riga orizzontale posizionata verticalmente in base all'orario di partenza: - - **Barra arancione** (`#F59E0B`): finestra check-in (da checkInOpen a checkInClose prima della partenza) - - **Barra blu** (`#3B82F6`): finestra gate (da gateOpen a gateClose prima della partenza) - - **Label** a sinistra: numero volo + destinazione IATA (es. "W6 1234 · BUD") -- Le barre sono posizionate orizzontalmente nella riga, proporzionali alla durata relativa delle finestre - -## Interazione - -- **Tap su un volo**: espande una card sotto la riga con: - - Compagnia aerea (nome completo) - - Orario partenza schedulato (HH:MM) - - CI Open / CI Close (orari esatti) - - Gate Open / Gate Close (orari esatti) - - Stato volo (Scheduled, Delayed, ecc. con colore) -- **Tap di nuovo**: chiude la card espansa -- **Solo un volo espanso alla volta** - -## Stati - -- **Loading**: `ActivityIndicator` centrato nel bottom sheet -- **Nessun volo**: testo "Nessuna partenza nel turno" centrato con emoji aereo -- **Errore fetch**: messaggio "Errore nel caricamento" con pulsante "Riprova" - -## Struttura file - -- Nuovo componente: `src/components/ShiftTimeline.tsx` - - Riceve: `shiftStart: Date`, `shiftEnd: Date`, `visible: boolean`, `onClose: () => void` - - Gestisce internamente: fetch voli, stato loading/error, espansione card -- HomeScreen: aggiunge `onPress` al pulsante "Dettagli Task" che apre il modal passando i dati del turno - -## Stile - -- Segue il tema corrente (`useAppTheme()` per colori) -- In weather/dark mode: sfondo `colors.bg`, niente elevation, bordi sottili come le altre card -- Barre con bordi arrotondati (borderRadius: 4) -- Legenda in alto sotto il titolo: pallino arancione "Check-in", pallino blu "Gate" diff --git a/docs/superpowers/specs/2026-03-27-manuals-editable-design.md b/docs/superpowers/specs/2026-03-27-manuals-editable-design.md deleted file mode 100644 index 021f9dd..0000000 --- a/docs/superpowers/specs/2026-03-27-manuals-editable-design.md +++ /dev/null @@ -1,89 +0,0 @@ -# Manuali DCS — Editing dall'app - -**Data:** 2026-03-27 -**Scope:** Aggiungere CRUD completo (compagnie, sezioni, voci) con persistenza locale in ManualsScreen. - ---- - -## Obiettivo - -Permettere all'utente di modificare i contenuti dei manuali DCS direttamente dall'app, senza accesso protetto, con persistenza tra sessioni. - ---- - -## Dati - -- I dati attuali (hardcoded in `AIRLINES`) diventano il **dataset di default**. -- Al mount, si legge da `AsyncStorage` (chiave `manuals_data`). Se assente → si usa il default e lo si persiste. -- Ogni modifica aggiorna lo state React e chiama `AsyncStorage.setItem` immediatamente. -- Struttura dati invariata: `Airline[] → Section[] → ManualItem[]`. - ---- - -## UI - -### Edit mode - -- Header "Manuali DCS" aggiunge un'icona ✏️ a destra. -- Toccandola → entra in edit mode (colore accent sull'icona come indicatore). -- Toccandola di nuovo → esce da edit mode. -- In edit mode i controlli di modifica appaiono; fuori da edit mode la schermata è identica all'attuale. - -### Compagnie (chip bar) - -- In edit mode: chip `+` alla fine per aggiungere una nuova compagnia. -- Pressione lunga su chip esistente → apre modal modifica/elimina compagnia. - -### Sezioni - -- In edit mode: ogni header sezione mostra un'icona ✏️ a destra → apre modal modifica/elimina sezione. -- In fondo alla lista sezioni (dentro il content): pulsante `+ Sezione` per aggiungere. - -### Voci (ManualItem) - -- In edit mode: ogni riga item mostra un'icona ✏️ a destra → apre modal modifica/elimina voce. -- In fondo a ogni sezione aperta: pulsante `+ Voce` per aggiungere. - ---- - -## Modal - -Tutti i modal sono `Modal` React Native con `animationType="slide"` e uno sfondo semi-trasparente. - -### Modal Compagnia -Campi: -- Nome (TextInput) -- Codice IATA (TextInput, max 2–3 char, uppercase) -- Colore (selezione da palette di ~10 colori predefiniti) - -Pulsanti: **Annulla** | **Salva** -In modalità modifica: aggiunta pulsante **Elimina** (con confirm dialog "Eliminare questa compagnia e tutti i suoi contenuti?"). - -### Modal Sezione -Campi: -- Titolo (TextInput) - -Pulsanti: **Annulla** | **Salva** | **Elimina** (con confirm). - -### Modal Voce -Campi: -- Titolo (TextInput) -- Corpo (TextInput multilinea, min 4 righe) - -Pulsanti: **Annulla** | **Salva** | **Elimina** (con confirm). - ---- - -## File coinvolti - -- `src/screens/ManualsScreen.tsx` — unico file da modificare -- Nessun nuovo file necessario (AsyncStorage già disponibile via `@react-native-async-storage/async-storage`) - ---- - -## Fuori scope - -- Sync cloud / backup -- Import/export -- Protezione con password -- Ordinamento manuale delle voci (drag & drop) diff --git a/docs/superpowers/specs/2026-03-28-pin-flight-design.md b/docs/superpowers/specs/2026-03-28-pin-flight-design.md deleted file mode 100644 index a736c1a..0000000 --- a/docs/superpowers/specs/2026-03-28-pin-flight-design.md +++ /dev/null @@ -1,104 +0,0 @@ -# Pin Flight — Design Spec - -## Overview - -Allow the user to pin a single flight from FlightScreen. The pinned flight appears prominently on HomeScreen and receives detailed operational notifications at each phase (check-in, gate, departure). - -## Behavior - -- **One pinned flight at a time.** Pinning a new flight replaces the previous one. -- **Auto-removal:** The pin is cleared automatically when the flight is completed (departed or landed). On app open / data refresh, check if the pinned flight's timestamp is in the past and clear it. -- **Persistence:** Pinned flight stored in AsyncStorage under a dedicated key (e.g. `pinned_flight_v1`). Stored data: the full flight item object from the FR24 API response, plus a `pinnedAt` timestamp. - -## Interaction — FlightScreen - -### Swipe to pin - -- Swipe left on a flight card reveals a gold PIN action button (background `#F59E0B`, 📌 icon + "PIN" label). -- Tapping the action pins the flight and shows a brief toast/alert confirmation. -- If a flight is already pinned, swiping on it reveals an "UNPIN" button instead. -- Swiping on a different flight while one is already pinned replaces the old pin. -- Works on both arrivals and departures tabs. - -### Implementation approach - -Use React Native's built-in `Animated` + `PanResponder` (already used in HomeScreen) to implement swipe-to-reveal on each flight card in `renderFlight`. No additional dependencies needed. The swipe gesture translates the card left to reveal the PIN/UNPIN action underneath. - -## HomeScreen — Pinned Flight Card - -### Position - -Top of the scroll content, immediately below the weather section, above the shift/calendar section. - -### Appearance - -- Card with gradient background (`linear-gradient(135deg, #1E3A8A, #2563EB)`) -- Gold border (2px, `#F59E0B`) -- Badge "📌 PINNATO" in gold pill, top-right corner -- Content: - - Flight number + destination (e.g. "U2 4521 → LGW") - - Airline name - - Operational times: Check-in open/close, Gate open/close, Departure (computed from `getAirlineOps`) - - Flight status with color indicator -- Tap on card navigates to FlightScreen (or no-op, keep it simple) -- When no flight is pinned, the card is not rendered (no empty state). - -### Data loading - -On HomeScreen mount (or refresh), read `pinned_flight_v1` from AsyncStorage. If found and the flight timestamp is still in the future, display the card. Otherwise clear the stale pin. - -## Notifications - -### Pinned flight notifications - -When a flight is pinned, schedule notifications for each operational phase (departures only, using `getAirlineOps` offsets): - -| Phase | Trigger time | Title | Body | -|-------|-------------|-------|------| -| Check-in open | `dep - checkInOpen min` | 📌 Check-in aperto — {flight} | Check-in aperto per il volo delle {time} → {dest} | -| Gate open | `dep - gateOpen min` | 📌 Gate aperto — {flight} | Gate aperto per il volo delle {time} → {dest} | -| Gate close | `dep - gateClose min` | 📌 Chiusura gate — {flight} | Gate in chiusura per il volo delle {time} → {dest} | -| Departure | `dep - 10 min` | 📌 Partenza tra 10 min — {flight} | {airline} → {dest} · partenza alle {time} | - -For pinned arrivals, schedule a single notification: "📌 Arrivo tra 15 min — {flight}". - -### Storage - -Pinned notification IDs stored separately (e.g. `pinned_notif_ids_v1`) so they can be cancelled independently when unpinning without affecting shift notifications. - -### Lifecycle - -- **On pin:** Cancel any existing pinned notifications, schedule new ones for the pinned flight. -- **On unpin:** Cancel pinned notifications. -- **On replace:** Cancel old, schedule new. -- **On auto-clear (flight completed):** Cancel pinned notifications. - -## Data flow - -``` -User swipes flight card - → save flight to AsyncStorage - → schedule pinned notifications - → update local state (for immediate UI feedback in FlightScreen) - -HomeScreen mounts / refreshes - → read pinned flight from AsyncStorage - → validate it's still in the future - → render card or clear stale pin - -Flight completes (detected on refresh) - → clear AsyncStorage - → cancel pinned notifications -``` - -## Files to modify - -- `src/screens/FlightScreen.tsx` — add swipe-to-pin on flight cards, pin/unpin logic, pinned notification scheduling -- `src/screens/HomeScreen.tsx` — add pinned flight card at top -- No new files needed; pin logic lives inline in the screens. - -## Out of scope - -- Live flight tracking / real-time status updates (we use cached FR24 data) -- Multiple pinned flights -- Pin history diff --git a/docs/superpowers/specs/2026-03-28-wearos-companion-design.md b/docs/superpowers/specs/2026-03-28-wearos-companion-design.md deleted file mode 100644 index ec0c530..0000000 --- a/docs/superpowers/specs/2026-03-28-wearos-companion-design.md +++ /dev/null @@ -1,105 +0,0 @@ -# WearOS Companion App — Design Spec - -## Overview - -App WearOS companion per AeroStaff Pro. Mostra gli orari operativi del volo pinnato dal telefono, con complicazione per il quadrante. Nessuna navigazione o selezione voli sul watch — tutto controllato dal telefono. - -Target: Samsung Galaxy Watch (Wear OS 4/5). - -## Schermata principale - -### Header -- Barra colorata con il colore della compagnia aerea -- Contenuto: numero volo + codice IATA destinazione/origine -- Formato partenze: `U2 8316 → LGW` -- Formato arrivi: `W6 3218 ← BUD` - -### Timeline verticale (Partenze) -Lista eventi operativi in sequenza con linea verticale di collegamento: -1. **CI Open** — orario apertura check-in -2. **CI Close** — orario chiusura check-in -3. **Gate** — orario apertura gate (da inbound se disponibile) -4. **Gate Close** — orario chiusura gate -5. **DEP** — orario partenza - -### Timeline verticale (Arrivi) -1. **Partito** — orario reale decollo dall'origine -2. **In volo** — tempo restante stimato -3. **Atterraggio** — orario stimato/schedulato - -### Stati eventi -- **Passato**: pallino verde, testo grigio barrato -- **Corrente/Prossimo**: pallino blu (partenze) o ambra (arrivi) con glow, sfondo evidenziato, testo bold 14px -- **Futuro**: pallino grigio, testo grigio chiaro - -### Countdown -- Fisso in basso al centro dello schermo -- Testo ambra bold 13px: "Gate tra X min", "+7 min ritardo", ecc. - -### Stato vuoto -- Quando nessun volo è pinnato: icona aereo + "Nessun volo" + "Pinna un volo dall'app" - -## Complicazione quadrante - -- Tipo: `SHORT_TEXT` con icona -- Contenuto: countdown al prossimo evento (es. "Gate 4m") -- Se nessun volo pinnato: "—" -- Tap → apre l'app watch - -## Sincronizzazione phone → watch - -### Tecnologia -Wearable Data Layer API (DataClient) per sync real-time. - -### Flusso dati -1. Telefono: al pin/unpin/refresh, scrive JSON nel DataClient al path `/pinned_flight` -2. Watch: DataClient.OnDataChangedListener riceve l'update e aggiorna UI -3. Al unpin: telefono scrive payload vuoto, watch mostra stato vuoto - -### Payload JSON -```json -{ - "flightNumber": "U2 8316", - "airline": "easyJet", - "airlineColor": "#FF6600", - "iataCode": "U2", - "tab": "departures", - "destination": "LGW", - "origin": "BUD", - "scheduledTime": 1774732500, - "estimatedTime": 1774732500, - "realDeparture": null, - "realArrival": null, - "ops": { - "checkInOpen": 120, - "checkInClose": 40, - "gateOpen": 30, - "gateClose": 20 - }, - "inboundArrival": 1774730000, - "pinnedAt": 1774720000 -} -``` - -### Notifiche -Le notifiche del volo pinnato dal telefono arrivano al watch tramite bridge Android nativo — nessuna implementazione aggiuntiva necessaria. - -## Architettura - -### Moduli -- `wear/` — modulo WearOS (Jetpack Compose, Kotlin) - - `WatchFaceComplication` — complicazione quadrante - - `MainActivity` — schermata principale con timeline - - `DataListenerService` — riceve dati dal telefono -- `app/` (telefono) — aggiunta DataClient send al pin/unpin/refresh - -### Dipendenze -- `com.google.android.gms:play-services-wearable` (phone + watch) -- Jetpack Compose for Wear OS (`androidx.wear.compose`) -- Horologist (utility Wear OS) - -## Leggibilità -- Font size minimo: 12px per eventi passati/futuri, 14px per evento corrente -- Label abbreviate: CI, DEP, Gate -- Spaziatura generosa tra righe (12px gap) -- Colori ad alto contrasto su sfondo scuro (#0F172A) diff --git a/get_all_sizes.js b/get_all_sizes.js deleted file mode 100644 index 756fd77..0000000 --- a/get_all_sizes.js +++ /dev/null @@ -1,58 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -function getDirSize(dirPath) { - let size = 0; - try { - const files = fs.readdirSync(dirPath); - for (const file of files) { - const filePath = path.join(dirPath, file); - const stats = fs.statSync(filePath); - if (stats.isDirectory()) { - size += getDirSize(filePath); - } else { - size += stats.size; - } - } - } catch (err) { - // Ignore errors - } - return size; -} - -const root = process.cwd(); -const items = fs.readdirSync(root); - -console.log('--- Sizes in ' + root + ' ---'); -const results = []; - -for (const item of items) { - const itemPath = path.join(root, item); - try { - const stats = fs.statSync(itemPath); - if (stats.isDirectory()) { - const size = getDirSize(itemPath); - results.push({ name: item + ' (Dir)', size: size }); - } else { - results.push({ name: item + ' (File)', size: stats.size }); - } - } catch (err) { - // Ignore - } -} - -// Sort by size descending -results.sort((a, b) => b.size - a.size); - -for (const res of results) { - const sizeGB = (res.size / (1024 * 1024 * 1024)).toFixed(2); - const sizeMB = (res.size / (1024 * 1024)).toFixed(2); - if (parseFloat(sizeGB) > 0.01) { - console.log(`${res.name}: ${sizeGB} GB`); - } else if (parseFloat(sizeMB) > 0.1) { - console.log(`${res.name}: ${sizeMB} MB`); - } else { - console.log(`${res.name}: < 0.1 MB`); - } -} -console.log('--- End ---'); diff --git a/get_sizes.ps1 b/get_sizes.ps1 deleted file mode 100644 index 18d7d9f..0000000 --- a/get_sizes.ps1 +++ /dev/null @@ -1,7 +0,0 @@ -Get-ChildItem -Directory -Force | ForEach-Object { - $size = (Get-ChildItem $_.FullName -Recurse -Force -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum - [PSCustomObject]@{ - Name = $_.Name - SizeGB = [Math]::Round($size / 1GB, 2) - } -} | Sort-Object SizeGB -Descending | Format-Table -AutoSize diff --git a/package-lock.json b/package-lock.json index a3bbe99..d9af9b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "flightworkapp", - "version": "1.1.0", + "version": "2.6.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "flightworkapp", - "version": "1.1.0", + "version": "2.6.4", "dependencies": { + "@expo/metro-runtime": "~6.1.2", "@expo/vector-icons": "^15.0.3", "@react-native-async-storage/async-storage": "2.2.0", - "@react-native-picker/picker": "2.11.1", + "@react-native-picker/picker": "2.11.4", "@types/tesseract.js": "^0.0.2", "expo": "~54.0.0", "expo-blur": "~15.0.8", @@ -23,18 +24,21 @@ "expo-linear-gradient": "~15.0.8", "expo-location": "~19.0.8", "expo-notifications": "~0.32.16", + "expo-secure-store": "~15.0.5", "expo-status-bar": "~3.0.9", "react": "19.1.0", + "react-dom": "19.1.0", "react-native": "0.81.5", "react-native-android-widget": "^0.20.1", "react-native-calendars": "^1.1314.0", - "react-native-webview": "13.15.0", + "react-native-web": "^0.21.0", + "react-native-webview": "13.16.1", "tesseract.js": "^7.0.0" }, "devDependencies": { "@react-native-community/cli": "^20.1.3", "@types/react": "~19.1.10", - "pdfjs-dist": "^5.5.207", + "pdfjs-dist": "^5.6.205", "typescript": "~5.9.2" } }, @@ -1765,6 +1769,30 @@ "metro-transform-worker": "0.83.3" } }, + "node_modules/@expo/metro-runtime": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-6.1.2.tgz", + "integrity": "sha512-nvM+Qv45QH7pmYvP8JB1G8JpScrWND3KrMA6ZKe62cwwNiX/BjHU28Ear0v/4bQWXlOY0mv6B8CDIm8JxXde9g==", + "license": "MIT", + "peer": true, + "dependencies": { + "anser": "^1.4.9", + "pretty-format": "^29.7.0", + "stacktrace-parser": "^0.1.10", + "whatwg-fetch": "^3.0.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-dom": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/@expo/osascript": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.4.2.tgz", @@ -2996,9 +3024,9 @@ } }, "node_modules/@react-native-picker/picker": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.11.1.tgz", - "integrity": "sha512-ThklnkK4fV3yynnIIRBkxxjxR4IFbdMNJVF6tlLdOJ/zEFUEFUEdXY0KmH0iYzMwY8W4/InWsLiA7AkpAbnexA==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.11.4.tgz", + "integrity": "sha512-Kf8h1AMnBo54b1fdiVylP2P/iFcZqzpMYcglC28EEFB1DEnOjsNr6Ucqc+3R9e91vHxEDnhZFbYDmAe79P2gjA==", "license": "MIT", "workspaces": [ "example" @@ -4578,6 +4606,15 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/cross-fetch": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", + "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4592,6 +4629,15 @@ "node": ">= 8" } }, + "node_modules/css-in-js-utils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz", + "integrity": "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==", + "license": "MIT", + "dependencies": { + "hyphenate-style-name": "^1.0.3" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -5239,6 +5285,15 @@ "react-native": "*" } }, + "node_modules/expo-secure-store": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-15.0.8.tgz", + "integrity": "sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-server": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz", @@ -5622,6 +5677,36 @@ "bser": "2.1.1" } }, + "node_modules/fbjs": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz", + "integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==", + "license": "MIT", + "dependencies": { + "cross-fetch": "^3.1.5", + "fbjs-css-vars": "^1.0.0", + "loose-envify": "^1.0.0", + "object-assign": "^4.1.0", + "promise": "^7.1.1", + "setimmediate": "^1.0.5", + "ua-parser-js": "^1.0.35" + } + }, + "node_modules/fbjs-css-vars": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz", + "integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==", + "license": "MIT" + }, + "node_modules/fbjs/node_modules/promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "license": "MIT", + "dependencies": { + "asap": "~2.0.3" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -6125,6 +6210,12 @@ "node": ">=10.17.0" } }, + "node_modules/hyphenate-style-name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", + "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", + "license": "BSD-3-Clause" + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -6251,6 +6342,15 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/inline-style-prefixer": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-7.0.1.tgz", + "integrity": "sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==", + "license": "MIT", + "dependencies": { + "css-in-js-utils": "^3.1.0" + } + }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -8412,16 +8512,16 @@ } }, "node_modules/pdfjs-dist": { - "version": "5.5.207", - "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.5.207.tgz", - "integrity": "sha512-WMqqw06w1vUt9ZfT0gOFhMf3wHsWhaCrxGrckGs5Cci6ybDW87IvPaOd2pnBwT6BJuP/CzXDZxjFgmSULLdsdw==", + "version": "5.6.205", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.6.205.tgz", + "integrity": "sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==", "dev": true, "license": "Apache-2.0", "engines": { "node": ">=20.19.0 || >=22.13.0 || >=24" }, "optionalDependencies": { - "@napi-rs/canvas": "^0.1.95", + "@napi-rs/canvas": "^0.1.96", "node-readable-to-web-readable-stream": "^0.4.2" } }, @@ -8513,6 +8613,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, "node_modules/pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", @@ -8731,6 +8837,19 @@ "ws": "^7" } }, + "node_modules/react-dom": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -8848,10 +8967,42 @@ "integrity": "sha512-Ns7Bn9H/Tyw278+5SQx9oAblDZ7JixyzeOczcBK8dipQk2pD7Djkcfnf1nB/8RErAmMLL9iXgW0QHqiII8AhKw==", "license": "MIT" }, + "node_modules/react-native-web": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz", + "integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.6", + "@react-native/normalize-colors": "^0.74.1", + "fbjs": "^3.0.4", + "inline-style-prefixer": "^7.0.1", + "memoize-one": "^6.0.0", + "nullthrows": "^1.1.1", + "postcss-value-parser": "^4.2.0", + "styleq": "^0.1.3" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-native-web/node_modules/@react-native/normalize-colors": { + "version": "0.74.89", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.74.89.tgz", + "integrity": "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg==", + "license": "MIT" + }, + "node_modules/react-native-web/node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, "node_modules/react-native-webview": { - "version": "13.15.0", - "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.15.0.tgz", - "integrity": "sha512-Vzjgy8mmxa/JO6l5KZrsTC7YemSdq+qB01diA0FqjUTaWGAGwuykpJ73MDj3+mzBSlaDxAEugHzTtkUQkQEQeQ==", + "version": "13.16.1", + "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.16.1.tgz", + "integrity": "sha512-If0eHhoEdOYDcHsX+xBFwHMbWBGK1BvGDQDQdVkwtSIXiq1uiqjkpWVP2uQ1as94J0CzvFE9PUNDuhiX0Z6ubw==", "license": "MIT", "peer": true, "dependencies": { @@ -9441,6 +9592,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -9845,6 +10002,12 @@ "integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==", "license": "MIT" }, + "node_modules/styleq": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/styleq/-/styleq-0.1.3.tgz", + "integrity": "sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==", + "license": "MIT" + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -10222,6 +10385,32 @@ "node": ">=14.17" } }, + "node_modules/ua-parser-js": { + "version": "1.0.41", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz", + "integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/undici": { "version": "6.24.1", "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", diff --git a/package.json b/package.json index 3f8b1c4..c001fe1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flightworkapp", - "version": "1.1.0", + "version": "2.6.4", "main": "index.ts", "scripts": { "start": "expo start", @@ -10,6 +10,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@expo/metro-runtime": "~6.1.2", "@expo/vector-icons": "^15.0.3", "@react-native-async-storage/async-storage": "2.2.0", "@react-native-picker/picker": "2.11.4", @@ -28,9 +29,11 @@ "expo-secure-store": "~15.0.5", "expo-status-bar": "~3.0.9", "react": "19.1.0", + "react-dom": "19.1.0", "react-native": "0.81.5", "react-native-android-widget": "^0.20.1", "react-native-calendars": "^1.1314.0", + "react-native-web": "^0.21.0", "react-native-webview": "13.16.1", "tesseract.js": "^7.0.0" }, diff --git a/rename_node_modules_builds.js b/rename_node_modules_builds.js deleted file mode 100644 index 8fb9645..0000000 --- a/rename_node_modules_builds.js +++ /dev/null @@ -1,43 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -const nodeModulesPath = path.join(__dirname, 'node_modules'); - -function renameBuildFolders(dir) { - if (!fs.existsSync(dir)) return; - - const files = fs.readdirSync(dir); - for (const file of files) { - const filePath = path.join(dir, file); - try { - const stats = fs.statSync(filePath); - if (stats.isDirectory()) { - if (file === 'android') { - const buildPath = path.join(filePath, 'build'); - if (fs.existsSync(buildPath)) { - const random = Math.floor(Math.random() * 100000); - const newPath = path.join(filePath, `build_old_${random}`); - try { - fs.renameSync(buildPath, newPath); - console.log(`Renamed: ${buildPath} -> ${newPath}`); - } catch (err) { - console.log(`Failed to rename ${buildPath}:`, err.message); - } - } - } else { - renameBuildFolders(filePath); - } - } - } catch (err) { - // Ignore stats errors (e.g. symlinks) - } - } -} - -if (fs.existsSync(nodeModulesPath)) { - console.log('Scanning node_modules for android/build folders...'); - renameBuildFolders(nodeModulesPath); - console.log('Finished scanning.'); -} else { - console.log('node_modules folder not found.'); -} diff --git a/src/components/AeroStaffLogo.tsx b/src/components/AeroStaffLogo.tsx new file mode 100644 index 0000000..86a5f2a --- /dev/null +++ b/src/components/AeroStaffLogo.tsx @@ -0,0 +1,197 @@ +import React from 'react'; +import { View, Text, StyleSheet, Platform } from 'react-native'; +import { LinearGradient } from 'expo-linear-gradient'; + +type Size = 'small' | 'large'; + +interface Props { + /** 'large' = icon + wordmark (drawer header); 'small' = icon only */ + variant?: Size; + /** White-only mode for use inside orange/dark headers */ + monochrome?: boolean; +} + +export default function AeroStaffLogo({ variant = 'large', monochrome = false }: Props) { + return ( + + + {variant === 'large' && ( + + AERO + + {!monochrome && ( + + )} + STAFF + + + PRO + + + )} + + ); +} + +function AeroIconMark({ small, monochrome }: { small: boolean; monochrome: boolean }) { + const S = small ? 36 : 44; + const R = small ? 9 : 11; + const scale = S / 44; + + const fuselageW = Math.round(22 * scale); + const fuselageH = Math.round(4 * scale); + const wingW = Math.round(20 * scale); + const wingH = Math.round(2.5 * scale); + const tailW = Math.round(8 * scale); + const tailH = Math.round(2 * scale); + + const white = '#FFFFFF'; + const orange = '#F47B16'; + const fg = monochrome ? orange : white; + + return ( + + {monochrome ? ( + + ) : ( + + )} + {/* Specular highlight */} + + {/* Aircraft mark */} + + {/* Upper wing */} + + {/* Fuselage */} + + {/* Lower wing */} + + {/* Tail fin */} + + + + ); +} + +const FONT = Platform.select({ ios: undefined, android: 'Roboto', default: undefined }); + +const styles = StyleSheet.create({ + root: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + rootSmall: { gap: 0 }, + wordmarkWrapper: { + flexDirection: 'row', + alignItems: 'center', + }, + wordmarkAero: { + fontSize: 19, + fontWeight: '700', + letterSpacing: 2.5, + color: '#FFFFFF', + fontFamily: FONT, + }, + wordmarkMono: { + color: '#FFFFFF', + }, + staffWrapper: { + overflow: 'hidden', + borderRadius: 2, + }, + wordmarkStaff: { + fontSize: 19, + fontWeight: '700', + letterSpacing: 2.5, + color: '#1C1C1E', + fontFamily: FONT, + }, + proBadge: { + marginLeft: 6, + backgroundColor: '#F47B16', + borderRadius: 4, + paddingHorizontal: 5, + paddingVertical: 2, + alignSelf: 'center', + marginBottom: 1, + }, + proBadgeMono: { + backgroundColor: '#FFFFFF', + }, + proBadgeText: { + fontSize: 8, + fontWeight: '800', + letterSpacing: 0.8, + color: '#FFFFFF', + fontFamily: FONT, + }, +}); + +const iconStyles = StyleSheet.create({ + container: { + overflow: 'hidden', + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#F47B16', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.45, + shadowRadius: 10, + elevation: 8, + }, + aircraft: { + alignItems: 'center', + justifyContent: 'center', + }, + fuselage: { + alignSelf: 'center', + }, + wing: { + alignSelf: 'flex-end', + borderRadius: 1, + }, + tail: { + alignSelf: 'flex-start', + marginTop: 1, + borderRadius: 1, + transform: [{ skewX: '-18deg' }], + }, +}); diff --git a/src/components/DrawerMenu.tsx b/src/components/DrawerMenu.tsx index 932d200..e22e073 100644 --- a/src/components/DrawerMenu.tsx +++ b/src/components/DrawerMenu.tsx @@ -1,9 +1,14 @@ +import { version } from '../../package.json'; import React, { useEffect, useRef, useState, useMemo } from 'react'; import { Animated, Modal, StyleSheet, Text, TouchableOpacity, View, } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; -import { useAppTheme } from '../context/ThemeContext'; +import { BlurView } from 'expo-blur'; +import { LinearGradient } from 'expo-linear-gradient'; +import { useAppTheme, type ThemeColors } from '../context/ThemeContext'; +import AeroStaffLogo from './AeroStaffLogo'; +import { useLanguage } from '../context/LanguageContext'; type DrawerItem = { id: string; @@ -12,14 +17,6 @@ type DrawerItem = { sublabel: string; }; -const ITEMS: DrawerItem[] = [ - { id: 'Notepad', icon: 'edit-note', label: 'Blocco Note', sublabel: 'Note personali' }, - { id: 'Phonebook', icon: 'contacts', label: 'Rubrica', sublabel: 'Numeri utili' }, - { id: 'Passwords', icon: 'lock', label: 'Password', sublabel: 'Credenziali salvate' }, - { id: 'Manuals', icon: 'menu-book', label: 'Manuali DCS', sublabel: 'Easyjet, Wizz, Ryanair…' }, - { id: 'Settings', icon: 'settings', label: 'Impostazioni', sublabel: 'Preferenze app' }, -]; - interface Props { visible: boolean; onClose: () => void; @@ -30,6 +27,14 @@ const DRAWER_WIDTH = 285; export default function DrawerMenu({ visible, onClose, onSelect }: Props) { const { colors } = useAppTheme(); + const { t } = useLanguage(); + const ITEMS: DrawerItem[] = [ + { id: 'Notepad', icon: 'edit-note', label: t('drawerNotepadTitle'), sublabel: t('drawerNotepadSub') }, + { id: 'Phonebook', icon: 'contacts', label: t('drawerPhonebookTitle'), sublabel: t('drawerPhonebookSub') }, + { id: 'Passwords', icon: 'lock', label: t('drawerPasswordTitle'), sublabel: t('drawerPasswordSub') }, + { id: 'Manuals', icon: 'menu-book', label: t('drawerManualsTitle'), sublabel: 'Easyjet, Wizz, Ryanair…' }, + { id: 'Settings', icon: 'settings', label: t('drawerSettingsTitle'), sublabel: t('drawerSettingsSub') }, + ]; const styles = useMemo(() => makeStyles(colors), [colors]); const slideAnim = useRef(new Animated.Value(-DRAWER_WIDTH)).current; const fadeAnim = useRef(new Animated.Value(0)).current; @@ -61,76 +66,109 @@ export default function DrawerMenu({ visible, onClose, onSelect }: Props) { {/* Drawer */} - - {/* Header */} - - - - - - AeroStaff Pro - Strumenti - - - - - - - {/* Section label */} - STRUMENTI + + + {/* Glass overlay tint */} + - {/* Menu items */} - - {ITEMS.map(item => ( - { onSelect(item.id); onClose(); }} - activeOpacity={0.7} - > - - - - - {item.label} - {item.sublabel} - - + {/* Orange gradient header */} + + + + - ))} - + + + {/* Section label */} + STRUMENTI + + {/* Menu items */} + + {ITEMS.map(item => ( + { onSelect(item.id); onClose(); }} + activeOpacity={0.7} + > + + + + + {item.label} + {item.sublabel} + + + + ))} + - {/* Divider */} - + {/* Divider */} + - AeroStaff Pro · v1.0 + AeroStaff Pro · v{version} + ); } -function makeStyles(c: any) { +function makeStyles(c: ThemeColors) { return StyleSheet.create({ root: { flex: 1, flexDirection: 'row' }, - overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(15,23,42,0.5)' }, - drawer: { - width: DRAWER_WIDTH, backgroundColor: c.card === 'transparent' ? c.bg : c.card, height: '100%', paddingTop: 52, - shadowColor: '#000', shadowOffset: { width: 6, height: 0 }, shadowOpacity: c.isDark ? 0 : 0.18, shadowRadius: 20, elevation: c.isDark ? 0 : 24, + overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(10,5,0,0.55)' }, + drawerWrapper: { + width: DRAWER_WIDTH, + height: '100%', + overflow: 'hidden', + // Subtle warm glow shadow + shadowColor: '#F97316', + shadowOffset: { width: 6, height: 0 }, + shadowOpacity: 0.12, + shadowRadius: 24, + elevation: 20, }, - header: { - flexDirection: 'row', alignItems: 'center', gap: 12, - paddingHorizontal: 18, paddingBottom: 20, - borderBottomWidth: 1, borderBottomColor: c.border, + blurFill: { + ...StyleSheet.absoluteFillObject, + }, + glassTint: { + ...StyleSheet.absoluteFillObject, + }, + headerGradient: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 18, + paddingTop: 56, + paddingBottom: 22, }, - logoCircle: { width: 42, height: 42, borderRadius: 21, backgroundColor: c.primary, justifyContent: 'center', alignItems: 'center' }, - appName: { fontSize: 15, fontWeight: '700', color: c.primaryDark }, - appSub: { fontSize: 11, color: c.textMuted, marginTop: 1 }, closeIconBtn: { padding: 6 }, - sectionLabel: { fontSize: 10, fontWeight: '700', color: c.textMuted, letterSpacing: 1.2, paddingHorizontal: 20, paddingTop: 20, paddingBottom: 8 }, + sectionLabel: { + fontSize: 10, fontWeight: '700', color: c.textMuted, + letterSpacing: 1.4, paddingHorizontal: 20, paddingTop: 20, paddingBottom: 8, + }, items: { paddingHorizontal: 10 }, - item: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 13, paddingHorizontal: 10, borderRadius: 14, marginBottom: 2 }, - itemIcon: { width: 40, height: 40, borderRadius: 12, backgroundColor: c.primaryLight, justifyContent: 'center', alignItems: 'center' }, + item: { + flexDirection: 'row', alignItems: 'center', gap: 12, + paddingVertical: 13, paddingHorizontal: 10, + borderRadius: 16, marginBottom: 2, + }, + itemIcon: { + width: 42, height: 42, borderRadius: 14, + backgroundColor: c.primaryLight, + justifyContent: 'center', alignItems: 'center', + }, itemLabel: { fontSize: 14, fontWeight: '600', color: c.text }, itemSub: { fontSize: 11, color: c.textMuted, marginTop: 1 }, divider: { height: 1, backgroundColor: c.border, marginHorizontal: 18, marginTop: 16 }, diff --git a/src/components/GlassCard.tsx b/src/components/GlassCard.tsx new file mode 100644 index 0000000..295c2cf --- /dev/null +++ b/src/components/GlassCard.tsx @@ -0,0 +1,160 @@ +import React from 'react'; +import { View, StyleSheet, Platform, ViewStyle } from 'react-native'; +import { BlurView } from 'expo-blur'; +import { LinearGradient } from 'expo-linear-gradient'; +import { useAppTheme } from '../context/ThemeContext'; + +type Variant = 'default' | 'strong' | 'subtle'; + +interface GlassCardProps { + children: React.ReactNode; + style?: ViewStyle; + variant?: Variant; + /** Force blur on/off. Defaults to iOS-only for performance. */ + useBlur?: boolean; +} + +const RADIUS = 20; + +export default function GlassCard({ + children, + style, + variant = 'default', + useBlur = Platform.OS === 'ios', +}: GlassCardProps) { + const { colors } = useAppTheme(); + + type VariantConfig = { + bgOverlay: string; + blurIntensity: number; + borderColor: string; + shimmer: [string, string]; + }; + + const variantMap: Record = { + default: { + bgOverlay: colors.glass, + blurIntensity: colors.isDark ? 80 : 70, + borderColor: colors.glassBorder, + shimmer: colors.isDark + ? ['rgba(255,255,255,0.07)', 'rgba(255,255,255,0.00)'] + : ['rgba(255,255,255,0.55)', 'rgba(255,255,255,0.00)'], + }, + strong: { + bgOverlay: colors.glassStrong, + blurIntensity: colors.isDark ? 90 : 80, + borderColor: colors.isDark ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,1.00)', + shimmer: colors.isDark + ? ['rgba(255,255,255,0.10)', 'rgba(255,255,255,0.00)'] + : ['rgba(255,255,255,0.70)', 'rgba(255,255,255,0.00)'], + }, + subtle: { + bgOverlay: colors.cardSecondary, + blurIntensity: 50, + borderColor: colors.border, + shimmer: ['rgba(255,255,255,0.04)', 'rgba(255,255,255,0.00)'], + }, + }; + + const v = variantMap[variant]; + + const shadowStyle: ViewStyle = colors.isDark + ? { + shadowColor: '#000000', + shadowOffset: { width: 0, height: 8 }, + shadowOpacity: 0.45, + shadowRadius: 24, + elevation: 12, + } + : { + shadowColor: colors.primary, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.12, + shadowRadius: 20, + elevation: 6, + }; + + // Android fallback: no BlurView, opaque background + const androidFallback: ViewStyle = { + backgroundColor: colors.isDark + ? 'rgba(28,28,32,0.96)' + : 'rgba(255,255,255,0.97)', + }; + + if (!useBlur) { + return ( + + + + + {children} + + + ); + } + + return ( + + + {/* Solid color overlay to control tint */} + + {/* Inner shimmer — specular top highlight */} + + {/* Border ring */} + + {/* Content */} + {children} + + + ); +} + +const styles = StyleSheet.create({ + outerShadow: { + borderRadius: RADIUS, + }, + blurContainer: { + borderRadius: RADIUS, + overflow: 'hidden', + }, + shimmer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: '55%', + borderTopLeftRadius: RADIUS, + borderTopRightRadius: RADIUS, + }, + border: { + borderRadius: RADIUS, + borderWidth: 0.75, + }, + content: { + padding: 16, + }, +}); diff --git a/src/components/ShiftTimeline.tsx b/src/components/ShiftTimeline.tsx index 902c665..9c7f60b 100644 --- a/src/components/ShiftTimeline.tsx +++ b/src/components/ShiftTimeline.tsx @@ -4,10 +4,11 @@ import { ActivityIndicator, Dimensions, LayoutAnimation, Platform, UIManager, } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; -import { useAppTheme } from '../context/ThemeContext'; +import { useAppTheme, type ThemeColors } from '../context/ThemeContext'; import { useAirport } from '../context/AirportContext'; import { getAirlineOps, getAirlineColor } from '../utils/airlineOps'; import { fetchAirportScheduleRaw } from '../utils/fr24api'; +import { useLanguage } from '../context/LanguageContext'; if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) { UIManager.setLayoutAnimationEnabledExperimental(true); @@ -40,7 +41,7 @@ function parseFlight(item: any): Flight | null { if (!f) return null; const ts = f.time?.scheduled?.departure; if (!ts) return null; - const airlineName = f.airline?.name || 'Sconosciuta'; + const airlineName = f.airline?.name || '—'; return { id: f.identification?.id || `${ts}`, flightNumber: f.identification?.number?.default || 'N/A', @@ -55,6 +56,7 @@ function parseFlight(item: any): Flight | null { export default function ShiftTimeline({ visible, onClose, shiftStart, shiftEnd, inline }: Props) { const { colors } = useAppTheme(); + const { t } = useLanguage(); const { airportCode, isLoading: airportLoading } = useAirport(); const [flights, setFlights] = useState([]); const [loading, setLoading] = useState(true); @@ -300,7 +302,7 @@ export default function ShiftTimeline({ visible, onClose, shiftStart, shiftEnd, ); } -function makeStyles(c: any) { +function makeStyles(c: ThemeColors) { return StyleSheet.create({ overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' }, sheet: { diff --git a/src/components/TimeCarouselPicker.tsx b/src/components/TimeCarouselPicker.tsx new file mode 100644 index 0000000..463c86d --- /dev/null +++ b/src/components/TimeCarouselPicker.tsx @@ -0,0 +1,193 @@ +import React, { useRef, useEffect, useCallback } from 'react'; +import { + View, + Text, + ScrollView, + StyleSheet, + NativeSyntheticEvent, + NativeScrollEvent, + Platform, +} from 'react-native'; +import { LinearGradient } from 'expo-linear-gradient'; + +const ITEM_H = 52; +const VISIBLE = 5; +const PAD = ITEM_H * 2; // 2 invisible items top & bottom + +interface WheelColumnProps { + items: string[]; + defaultIndex: number; + onChange: (index: number) => void; + accentColor: string; + textColor: string; + mutedColor: string; + bgColor: string; + borderColor: string; +} + +const WheelColumn: React.FC = ({ + items, defaultIndex, onChange, + accentColor, textColor, mutedColor, bgColor, borderColor, +}) => { + const scrollRef = useRef(null); + const lastIndex = useRef(defaultIndex); + + useEffect(() => { + const timer = setTimeout(() => { + scrollRef.current?.scrollTo({ y: defaultIndex * ITEM_H, animated: false }); + }, 80); + return () => clearTimeout(timer); + }, []); + + const onMomentumEnd = useCallback((e: NativeSyntheticEvent) => { + const rawIdx = e.nativeEvent.contentOffset.y / ITEM_H; + const idx = Math.min(Math.max(Math.round(rawIdx), 0), items.length - 1); + if (idx !== lastIndex.current) { + lastIndex.current = idx; + onChange(idx); + } + }, [items.length, onChange]); + + return ( + + {/* Selection highlight */} + + + {items.map((label, i) => ( + + + {label} + + + ))} + + {/* Top fade */} + + {/* Bottom fade */} + + + ); +}; + +const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0')); +const MINUTES = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0')); + +interface TimeCarouselPickerProps { + hour: number; + minute: number; + onHourChange: (h: number) => void; + onMinuteChange: (m: number) => void; + accentColor: string; + textColor: string; + mutedColor: string; + bgColor: string; + borderColor: string; +} + +const TimeCarouselPicker: React.FC = ({ + hour, minute, + onHourChange, onMinuteChange, + accentColor, textColor, mutedColor, bgColor, borderColor, +}) => ( + + + : + + +); + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 14, + borderWidth: 1, + overflow: 'hidden', + paddingHorizontal: 12, + marginVertical: 4, + }, + selectionRect: { + position: 'absolute', + left: 4, + right: 4, + height: ITEM_H, + borderRadius: 10, + borderWidth: 1.5, + zIndex: 1, + }, + itemContainer: { + height: ITEM_H, + alignItems: 'center', + justifyContent: 'center', + }, + itemText: { + fontSize: 26, + fontWeight: '600', + fontVariant: ['tabular-nums'], + }, + colon: { + fontSize: 28, + fontWeight: '700', + marginHorizontal: 6, + marginBottom: 2, + }, + fade: { + position: 'absolute', + left: 0, + right: 0, + height: PAD, + zIndex: 2, + pointerEvents: 'none', + }, + fadeTop: { top: 0 }, + fadeBottom: { bottom: 0 }, +}); + +export default TimeCarouselPicker; diff --git a/src/context/LanguageContext.tsx b/src/context/LanguageContext.tsx new file mode 100644 index 0000000..6bb8e2a --- /dev/null +++ b/src/context/LanguageContext.tsx @@ -0,0 +1,63 @@ +import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { + translations, MONTHS, WEEKDAYS_SHORT, WEEKDAYS_LONG, LOCALE_MAP, LANGUAGES, WEATHER_MAP, + type Lang, type TranslationKey, +} from '../i18n/translations'; + +const STORAGE_KEY = 'aerostaff_language_v1'; + +interface LanguageContextValue { + lang: Lang; + setLang: (lang: Lang) => Promise; + t: (key: TranslationKey) => string; + months: string[]; + weekDaysShort: string[]; + weekDaysLong: string[]; + locale: string; + weatherMap: Record; + languages: typeof LANGUAGES; +} + +const LanguageContext = createContext(undefined); + +export function LanguageProvider({ children }: { children: React.ReactNode }) { + const [lang, setLangState] = useState('it'); + + useEffect(() => { + AsyncStorage.getItem(STORAGE_KEY).then(stored => { + if (stored === 'it' || stored === 'en') setLangState(stored as Lang); + }); + }, []); + + const setLang = useCallback(async (next: Lang) => { + setLangState(next); + await AsyncStorage.setItem(STORAGE_KEY, next); + }, []); + + const t = useCallback( + (key: TranslationKey): string => + (translations[lang] as Record)[key] ?? + (translations.it as Record)[key] ?? + key, + [lang] + ); + + const value = useMemo(() => ({ + lang, setLang, t, + months: MONTHS[lang], + weekDaysShort: WEEKDAYS_SHORT[lang], + weekDaysLong: WEEKDAYS_LONG[lang], + locale: LOCALE_MAP[lang], + weatherMap: WEATHER_MAP[lang], + languages: LANGUAGES, + }), [lang, setLang, t]); + + return {children}; +} + +export function useLanguage(): LanguageContextValue { + const ctx = useContext(LanguageContext); + if (!ctx) throw new Error('useLanguage must be used inside LanguageProvider'); + return ctx; +} diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx index f56c9b1..be3fd53 100644 --- a/src/context/ThemeContext.tsx +++ b/src/context/ThemeContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; +import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { useDynamicTheme } from '../hooks/useDynamicTheme'; @@ -18,6 +18,10 @@ export type ThemeColors = { primary: string; primaryDark: string; primaryLight: string; + // Glass tokens (liquid glass aesthetic) + glass: string; + glassBorder: string; + glassStrong: string; // UI border: string; appBar: string; @@ -37,44 +41,50 @@ export type ThemeColors = { // ─── Tema Chiaro ────────────────────────────────────────────────────────────── const LIGHT: ThemeColors = { - bg: '#F3F4F6', - card: '#FFFFFF', - cardSecondary: '#F8FAFC', - text: '#1E293B', - textSub: '#64748B', - textMuted: '#94A3B8', - primary: '#2563EB', - primaryDark: '#1E3A8A', - primaryLight: '#DBEAFE', - border: '#E5E7EB', - appBar: '#FFFFFF', - tabBar: '#FFFFFF', - tabIconActive: '#FFFFFF', - tabIconInactive:'#94A3B8', - tabLabelActive: '#2563EB', - pillActive: '#2563EB', + bg: '#F2F2F7', + card: 'rgba(255,255,255,0.72)', + cardSecondary: 'rgba(242,242,247,0.80)', + text: '#1C1C1E', + textSub: '#48484A', + textMuted: 'rgba(60,60,67,0.45)', + primary: '#F47B16', + primaryDark: '#C2520A', + primaryLight: '#FFEDD5', + glass: 'rgba(255,255,255,0.58)', + glassBorder: 'rgba(255,255,255,0.88)', + glassStrong: 'rgba(255,255,255,0.84)', + border: 'rgba(60,60,67,0.18)', + appBar: 'rgba(242,242,247,0.85)', + tabBar: 'rgba(255,255,255,0.90)', + tabIconActive: '#F47B16', + tabIconInactive:'rgba(60,60,67,0.38)', + tabLabelActive: '#F47B16', + pillActive: 'rgba(244,123,22,0.14)', statusBar: 'dark-content', isDark: false, }; // ─── Tema Scuro ─────────────────────────────────────────────────────────────── const DARK: ThemeColors = { - bg: '#0F172A', - card: '#1E293B', - cardSecondary: '#0F172A', - text: '#F1F5F9', - textSub: '#94A3B8', - textMuted: '#475569', - primary: '#3B82F6', - primaryDark: '#93C5FD', - primaryLight: '#1E3A5F', - border: '#334155', - appBar: '#1E293B', - tabBar: '#1E293B', - tabIconActive: '#FFFFFF', - tabIconInactive:'#475569', - tabLabelActive: '#60A5FA', - pillActive: '#3B82F6', + bg: '#0A0A0C', + card: 'rgba(255,255,255,0.07)', + cardSecondary: 'rgba(255,255,255,0.04)', + text: '#FFFFFF', + textSub: 'rgba(235,235,245,0.75)', + textMuted: 'rgba(235,235,245,0.38)', + primary: '#FF9A42', + primaryDark: '#F47B16', + primaryLight: 'rgba(255,154,66,0.20)', + glass: 'rgba(255,255,255,0.06)', + glassBorder: 'rgba(255,255,255,0.13)', + glassStrong: 'rgba(28,28,30,0.84)', + border: 'rgba(255,255,255,0.11)', + appBar: 'rgba(10,10,12,0.82)', + tabBar: 'rgba(10,10,12,0.90)', + tabIconActive: '#FF9A42', + tabIconInactive:'rgba(235,235,245,0.35)', + tabLabelActive: '#FF9A42', + pillActive: 'rgba(255,154,66,0.18)', statusBar: 'light-content', isDark: true, }; @@ -86,19 +96,22 @@ function weatherToColors(gradient: readonly [string, string, ...string[]], icon: bg: dominant, card: 'transparent', cardSecondary: 'transparent', - text: '#E8F4FD', - textSub: '#A8C5E8', - textMuted: '#7BA3CA', - primary: '#136DEC', - primaryDark: '#93C5FD', - primaryLight: 'rgba(255,255,255,0.12)', + text: '#FFFFFF', + textSub: 'rgba(235,235,245,0.75)', + textMuted: 'rgba(235,235,245,0.45)', + primary: '#FF9A42', + primaryDark: '#F47B16', + primaryLight: 'rgba(255,154,66,0.20)', + glass: 'rgba(255,255,255,0.15)', + glassBorder: 'rgba(255,255,255,0.25)', + glassStrong: 'rgba(255,255,255,0.22)', border: 'rgba(255,255,255,0.15)', appBar: dominant, tabBar: gradient[gradient.length - 1], - tabIconActive: '#FFFFFF', + tabIconActive: '#FF9A42', tabIconInactive:'rgba(255,255,255,0.45)', tabLabelActive: '#FFFFFF', - pillActive: 'rgba(255,255,255,0.25)', + pillActive: 'rgba(255,255,255,0.30)', statusBar: 'light-content', isDark: true, gradient, @@ -143,18 +156,19 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) { await AsyncStorage.setItem(STORAGE_KEY, m); }, []); - // Calcola colori attivi - let colors: ThemeColors = LIGHT; - if (mode === 'dark') { - colors = DARK; - } else if (mode === 'weather') { - colors = weatherToColors(weatherTheme.background, weatherTheme.icon, weatherTheme.description); - } + // Calcola colori attivi (memoizzato per evitare re-render a cascata) + const colors = useMemo(() => { + if (mode === 'dark') return DARK; + if (mode === 'weather') return weatherToColors(weatherTheme.background, weatherTheme.icon, weatherTheme.description); + return LIGHT; + }, [mode, weatherTheme]); const isLoading = !ready || (mode === 'weather' && loadingTheme); + const value = useMemo(() => ({ mode, colors, setMode, isLoading }), [mode, colors, setMode, isLoading]); + return ( - + {children} ); diff --git a/src/hooks/useDynamicTheme.ts b/src/hooks/useDynamicTheme.ts index b32b0eb..8570ed4 100644 --- a/src/hooks/useDynamicTheme.ts +++ b/src/hooks/useDynamicTheme.ts @@ -118,7 +118,7 @@ async function resolveTheme(): Promise { globalCachedTheme = selected; return selected; } catch (err) { - console.warn('Errore caricamento tema dinamico:', err); + if (__DEV__) console.warn('Errore caricamento tema dinamico:', err); return themes.default; } finally { pendingFetch = null; diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts new file mode 100644 index 0000000..e1d798e --- /dev/null +++ b/src/i18n/translations.ts @@ -0,0 +1,365 @@ +export type Lang = 'it' | 'en'; + +const it = { + // Navigation + tabHome: 'Home', tabShifts: 'Turni', tabFlights: 'Voli', tabTravelDoc: 'TravelDoc', + overlayNotepad: 'Blocco Note', overlayPhonebook: 'Rubrica', overlayPasswords: 'Password', + overlayManuals: 'Manuali DCS', overlaySettings: 'Impostazioni', + // Common + cancel: 'Annulla', save: 'Salva', delete: 'Elimina', error: 'Errore', + confirm: 'Conferma', ok: 'OK', add: 'Aggiungi', + // Settings + settingsTitle: 'Impostazioni', + sectionTheme: 'TEMA', + themeLight: 'Chiaro', themeLightSub: 'Tema standard, sfondo bianco', + themeDark: 'Scuro', themeDarkSub: 'Ideale di notte, riduce affaticamento', + themeWeather: 'Meteo', themeWeatherSub: 'Cambia in base a cielo, ora e meteo', + themeActive: 'Attivo', themeLoading: 'Caricamento tema meteo…', + sectionAccount: 'ACCOUNT', + accountProfile: 'Profilo', accountProfileSub: 'Nome, ruolo, compagnia', + accountId: 'Matricola / ID', accountIdSub: 'Non configurato', + sectionAirport: 'AEROPORTO', + airportBase: 'Aeroporto base', airportLoading: 'Caricamento aeroporto...', + airportAirlines: 'Compagnie monitorate', airportAirlinesSub: 'Wizz, easyJet, Ryanair…', + sectionNotifications: 'NOTIFICHE', + notifFlights: 'Notifiche voli', notifFlightsSub: 'Gestito nella tab Voli', + notifReminder: 'Promemoria turno', notifReminderSub: 'Prossimamente', + sectionApp: 'APP', appVersion: 'Versione', sectionLanguage: 'LINGUA', + airportModalTitle: 'Cambia aeroporto', + airportModalCopy: 'Inserisci un codice IATA di 3 lettere. Il cambio aggiorna voli, timeline e notifiche.', + airportModalLabel: 'Codice aeroporto', airportModalQuickPick: 'Scelta rapida', + airportModalSave: 'Salva', + airportAlertInvalidTitle: 'Codice non valido', + airportAlertInvalidMsg: 'Inserisci un codice IATA di 3 lettere, per esempio PSA o FCO.', + airportAlertUpdatedTitle: 'Aeroporto aggiornato', + airportAlertUpdatedMsg: 'Voli, timeline, widget e notifiche useranno il nuovo aeroporto.', + airportAlertErrorTitle: 'Errore', airportAlertErrorMsg: "Non sono riuscito a salvare il nuovo aeroporto.", + // Notepad + notepadTitle: 'Blocco Note', notepadSave: 'Salva', notepadSaved: 'Salvato', + notepadUnsaved: 'Modifiche non salvate', notepadChars: 'caratteri', + notepadClearTitle: 'Cancella note', + notepadClearMsg: 'Sei sicuro di voler cancellare tutte le note?', + notepadClearConfirm: 'Cancella', notepadPlaceholder: 'Inizia a scrivere...', + // TravelDoc + traveldocSub: 'Verifica documenti di viaggio', + traveldocLoading: 'Caricamento TravelDoc…', + traveldocSlowConn: 'Caricamento lento. Verifica la connessione internet.', + // ShiftScreen + shiftTitle: 'Gestione Turni', + shiftSub: 'Scansiona i turni dal tabellone e sincronizzali nel calendario.', + shiftSyncTitle: '\ud83d\udcc5 Sincronizzazione Calendario', + shiftSyncDesc: 'Seleziona gli screenshot del tuo tabellone orari...', + shiftScanBtn: '\ud83d\udcf7 Scansiona Screenshot Turni', + shiftExtracting: 'Estrazione del testo in corso...', + shiftExtractedTitle: 'Testo Estratto:', + shiftSyncBtn: '\u2705 Sincronizza nel Calendario!', + shiftErrOcrTitle: 'Errore OCR', shiftErrOcrMsg: "Impossibile elaborare l'immagine.", + shiftPermTitle: 'Permesso negato', + shiftPermMsg: "Devi autorizzare l'accesso al calendario del telefono.", + shiftNoCalendar: 'Nessun calendario scrivibile trovato sul dispositivo.', + shiftSyncOkTitle: '\u2705 Turni Sincronizzati!', + shiftNoShifts: 'Nessun orario trovato', shiftCalErrTitle: 'Errore Calendario', + // Calendar + calTitle: 'Gestione Turni', calEditBtn: 'Modifica Turni', + calWeatherLocal: 'Meteo locale', calShiftWork: 'Turno Lavoro', + calRestDay: 'Giorno di Riposo', calNoShift: 'Nessun turno per', + calEditMenuTitle: 'Modifica Turni', + calImportPdf: 'Importa da PDF', calImportPdfSub: 'Carica il foglio turni aziendale', + calAddManual: 'Aggiungi manualmente', calAddManualSub: 'Seleziona giorno e orario', + calAddShiftTitle: 'Aggiungi Turno', calDataLabel: 'DATA', + calDataHint: 'Seleziona un giorno dal calendario per cambiare la data', + calTypeLabel: 'TIPO', calTypeWork: '\u2708\ufe0f Lavoro', calTypeRest: '\ud83c\udf34 Riposo', + calStartTime: 'ORARIO INIZIO', calEndTime: 'ORARIO FINE', + calSaveShift: 'Salva Turno', calImportTitle: 'Importa Turni', + calExtracting: 'Estrazione testo dal PDF...', + calPickName: 'Seleziona il tuo nome', calShiftsOf: 'Turni di', + calChangeName: 'Cambia nome', calSaveToCalendar: 'Salva nel calendario', + calSaving: 'Salvataggio in corso...', calImportDone: 'Turni importati!', + calRestPill: 'Riposo', calPermDenied: 'Permesso negato', + calNoWritableCalendar: 'Nessun calendario scrivibile', calShiftSaved: 'Turno salvato!', + calImportComplete: 'Importazione completata', calImportError: 'Errore durante il salvataggio', + calNoPdfText: 'Impossibile estrarre il testo dal PDF', + calNoEmployees: 'Nessun dipendente trovato nel PDF', + // Home + homeToday: 'OGGI', homeCurrentShift: 'Turno Attuale', + homeShiftWork: 'Turno Lavoro \u2708\ufe0f', homeInProgress: 'IN CORSO', + homeRestDay: 'Giorno di Riposo', homeNoShift: 'Nessun turno per oggi', + homeArrival: 'Arrivo', homeDeparture: 'Partenza', homePinned: 'Pinnato', homeWeatherLocal: 'Locale', + homePermDenied: 'Permesso negato', homeNoWritableCalendar: 'Nessun calendario scrivibile.', + homeShiftSynced: '\u2705 Turni Sincronizzati!', homeShiftsSaved: 'turni salvati.', + homeNoSchedule: 'Nessun orario trovato', homeCalErr: 'Errore Calendario', + homeCalendarAuth: 'Autorizza il calendario.', + // Flight + flightTitle: 'Voli in tempo reale', flightArrivals: 'Arrivi', flightDepartures: 'Partenze', + flightToday: 'Oggi', flightTomorrow: 'Domani', + flightNoFlights: 'Nessun volo per questo giorno.', + flightCheckin: 'Check-in', flightGate: 'Gate', flightStand: 'Stand', flightBelt: 'Nastro', flightDeparted: 'Partito', + flightLanded: 'Atterrato', flightEstimated: 'Stimato', flightOnTime: 'In orario', + flightPinned: 'PINNATO', flightPinnedLabel: 'Pinnato', + flightNotifEnabled: 'Notifiche attivate', + flightNotifPermDenied: 'Permesso negato', + flightNotifPermMsg: 'Abilita le notifiche nelle impostazioni del telefono per usare questa funzione.', + flightNoShift: 'Nessun turno trovato', + flightNoShiftMsg: 'Non ho trovato un turno "Lavoro" per oggi nel calendario.', + flightNotifMsg1: 'Programmate {count} notifiche: arrivi voli (15 min prima) + fine turno.', + flightNotifMsg0: 'Nessun volo futuro trovato, ma riceverai la notifica di fine turno.', + flightNotifAccessEnable: 'Attiva notifiche voli', flightNotifAccessDisable: 'Disattiva notifiche voli', + // Phonebook + phonebookTitle: 'Rubrica', contactAdd: 'Aggiungi', + contactSearch: 'Cerca nome o numero...', contactAll: 'Tutti', + contactModalNew: 'Nuovo contatto', contactModalEdit: 'Modifica contatto', + contactNameLabel: 'Nome *', contactNamePh: 'es. OPS Malpensa', + contactNumberLabel: 'Numero *', contactNumberPh: '+39 02 1234567', + contactCatLabel: 'Categoria', contactNoteLabel: 'Nota (opzionale)', + contactNotePh: 'es. Solo orario ufficio, interno 3...', + contactErrReqTitle: 'Campi obbligatori', contactErrReqMsg: 'Nome e numero sono obbligatori.', + contactDeleteTitle: 'Elimina contatto', contactDeleteConfirm: 'Elimina', + contactEmptyTitle: 'Rubrica vuota', + contactEmptyHint: 'Tocca "Aggiungi" per inserire il primo contatto', + contactNoResults: 'Nessun risultato', + // Password + passwordTitle: 'Password', passwordAdd: 'Aggiungi', + passwordModalNew: 'Nuova voce', passwordModalEdit: 'Modifica voce', + passwordNameLabel: 'Nome *', passwordNamePh: 'es. Portale HR EasyJet', + passwordUsernameLabel: 'Username / Email', passwordUsernamePh: 'es. mario.rossi@easyjet.com', + passwordPwLabel: 'Password *', passwordNotesLabel: 'Note', + passwordNotesPh: 'es. scade ogni 90 giorni\u2026', + passwordErrName: 'Il nome \u00e8 obbligatorio.', + passwordErrPw: 'La password \u00e8 obbligatoria.', + passwordDeleteTitle: 'Elimina', passwordDeleteMsg: 'Vuoi eliminare questa voce?', + passwordEmptyTxt: 'Nessuna password salvata.', passwordEmptySubTxt: 'Premi "Aggiungi" per iniziare.', + pinInsert: 'Inserisci PIN', pinSetup: 'Imposta PIN (4 cifre)', + pinSetTitle: 'PIN impostato', pinSetMsg: 'La schermata password \u00e8 ora protetta.', + pinWrong: 'PIN errato', pinWrongMsg: 'Riprova.', + pinDisableTitle: 'Disattiva PIN', pinDisableMsg: 'Vuoi rimuovere la protezione PIN?', + pinDisableBtn: 'Disattiva', pinErrMsg: 'Impossibile impostare il PIN. Riprova.', + pinVerifyErr: 'Impossibile verificare il PIN. Riprova.', + pinAccessEnable: 'Attiva protezione PIN', pinAccessDisable: 'Disattiva protezione PIN', + // DrawerMenu + drawerNotepadTitle: 'Blocco Note', + drawerNotepadSub: 'Note personali', + drawerPhonebookTitle: 'Rubrica', + drawerPhonebookSub: 'Numeri utili', + drawerPasswordTitle: 'Password', + drawerPasswordSub: 'Credenziali salvate', + drawerManualsTitle: 'Manuali DCS', + drawerManualsSub: 'Libreria documenti', + drawerSettingsTitle: 'Impostazioni', + drawerSettingsSub: 'Preferenze app', + // ShiftTimeline + shiftUnknown: 'Sconosciuta', + // FlightScreen filter menu + flightFilterTitle: 'Mostra voli', + flightFilterMine: 'Mie compagnie', + flightFilterMineSub: 'Wizz, EasyJet e altri operatori configurati', + flightFilterAll: 'Tutti i voli', + flightFilterAllSub: 'Mostra tutti gli operatori presenti', + flightAirlineSearch: 'Cerca compagnia...', + flightAirlineEmpty: 'Apri la tab Voli per caricare le compagnie', +}; + +const en: typeof it = { + // Navigation + tabHome: 'Home', tabShifts: 'Shifts', tabFlights: 'Flights', tabTravelDoc: 'TravelDoc', + overlayNotepad: 'Notepad', overlayPhonebook: 'Phonebook', overlayPasswords: 'Password', + overlayManuals: 'DCS Manuals', overlaySettings: 'Settings', + // Common + cancel: 'Cancel', save: 'Save', delete: 'Delete', error: 'Error', + confirm: 'Confirm', ok: 'OK', add: 'Add', + // Settings + settingsTitle: 'Settings', + sectionTheme: 'THEME', + themeLight: 'Light', themeLightSub: 'Standard theme, white background', + themeDark: 'Dark', themeDarkSub: 'Ideal at night, reduces eye strain', + themeWeather: 'Weather', themeWeatherSub: 'Changes based on sky, time and weather', + themeActive: 'Active', themeLoading: 'Loading weather theme…', + sectionAccount: 'ACCOUNT', + accountProfile: 'Profile', accountProfileSub: 'Name, role, company', + accountId: 'Badge / ID', accountIdSub: 'Not configured', + sectionAirport: 'AIRPORT', + airportBase: 'Home airport', airportLoading: 'Loading airport...', + airportAirlines: 'Monitored airlines', airportAirlinesSub: 'Wizz, easyJet, Ryanair…', + sectionNotifications: 'NOTIFICATIONS', + notifFlights: 'Flight notifications', notifFlightsSub: 'Managed in Flights tab', + notifReminder: 'Shift reminder', notifReminderSub: 'Coming soon', + sectionApp: 'APP', appVersion: 'Version', sectionLanguage: 'LANGUAGE', + airportModalTitle: 'Change airport', + airportModalCopy: 'Enter a 3-letter IATA code. This updates flights, timeline and notifications.', + airportModalLabel: 'Airport code', airportModalQuickPick: 'Quick pick', + airportModalSave: 'Save', + airportAlertInvalidTitle: 'Invalid code', + airportAlertInvalidMsg: 'Enter a 3-letter IATA code, for example PSA or FCO.', + airportAlertUpdatedTitle: 'Airport updated', + airportAlertUpdatedMsg: 'Flights, timeline, widgets and notifications will use the new airport.', + airportAlertErrorTitle: 'Error', airportAlertErrorMsg: 'Could not save the new airport.', + // Notepad + notepadTitle: 'Notepad', notepadSave: 'Save', notepadSaved: 'Saved', + notepadUnsaved: 'Unsaved changes', notepadChars: 'characters', + notepadClearTitle: 'Clear notes', + notepadClearMsg: 'Are you sure you want to clear all notes?', + notepadClearConfirm: 'Clear', notepadPlaceholder: 'Start writing...', + // TravelDoc + traveldocSub: 'Travel document check', + traveldocLoading: 'Loading TravelDoc…', + traveldocSlowConn: 'Slow loading. Check your internet connection.', + // ShiftScreen + shiftTitle: 'Shift Manager', + shiftSub: 'Scan shifts from the schedule board and sync them to the calendar.', + shiftSyncTitle: '\ud83d\udcc5 Calendar Sync', + shiftSyncDesc: 'Select screenshots of your schedule board...', + shiftScanBtn: '\ud83d\udcf7 Scan Shift Screenshots', + shiftExtracting: 'Extracting text...', + shiftExtractedTitle: 'Extracted Text:', + shiftSyncBtn: '\u2705 Sync to Calendar!', + shiftErrOcrTitle: 'OCR Error', shiftErrOcrMsg: 'Could not process the image.', + shiftPermTitle: 'Permission denied', + shiftPermMsg: 'You need to grant access to the phone calendar.', + shiftNoCalendar: 'No writable calendar found on the device.', + shiftSyncOkTitle: '\u2705 Shifts Synced!', + shiftNoShifts: 'No schedules found', shiftCalErrTitle: 'Calendar Error', + // Calendar + calTitle: 'Shift Manager', calEditBtn: 'Edit Shifts', + calWeatherLocal: 'Local weather', calShiftWork: 'Work Shift', + calRestDay: 'Rest Day', calNoShift: 'No shift for', + calEditMenuTitle: 'Edit Shifts', + calImportPdf: 'Import from PDF', calImportPdfSub: 'Upload company schedule sheet', + calAddManual: 'Add manually', calAddManualSub: 'Select day and time', + calAddShiftTitle: 'Add Shift', calDataLabel: 'DATE', + calDataHint: 'Select a day from the calendar to change the date', + calTypeLabel: 'TYPE', calTypeWork: '\u2708\ufe0f Work', calTypeRest: '\ud83c\udf34 Rest', + calStartTime: 'START TIME', calEndTime: 'END TIME', + calSaveShift: 'Save Shift', calImportTitle: 'Import Shifts', + calExtracting: 'Extracting text from PDF...', + calPickName: 'Select your name', calShiftsOf: 'Shifts of', + calChangeName: 'Change name', calSaveToCalendar: 'Save to calendar', + calSaving: 'Saving...', calImportDone: 'Shifts imported!', + calRestPill: 'Rest', calPermDenied: 'Permission denied', + calNoWritableCalendar: 'No writable calendar', calShiftSaved: 'Shift saved!', + calImportComplete: 'Import complete', calImportError: 'Error during save', + calNoPdfText: 'Could not extract text from PDF', + calNoEmployees: 'No employees found in PDF', + // Home + homeToday: 'TODAY', homeCurrentShift: 'Current Shift', + homeShiftWork: 'Work Shift \u2708\ufe0f', homeInProgress: 'IN PROGRESS', + homeRestDay: 'Rest Day', homeNoShift: 'No shift today', + homeArrival: 'Arrival', homeDeparture: 'Departure', homePinned: 'Pinned', homeWeatherLocal: 'Local', + homePermDenied: 'Permission denied', homeNoWritableCalendar: 'No writable calendar.', + homeShiftSynced: '\u2705 Shifts Synced!', homeShiftsSaved: 'shifts saved.', + homeNoSchedule: 'No schedule found', homeCalErr: 'Calendar Error', + homeCalendarAuth: 'Authorize the calendar.', + // Flight + flightTitle: 'Real-time Flights', flightArrivals: 'Arrivals', flightDepartures: 'Departures', + flightToday: 'Today', flightTomorrow: 'Tomorrow', + flightNoFlights: 'No flights for this day.', + flightCheckin: 'Check-in', flightGate: 'Gate', flightStand: 'Stand', flightBelt: 'Belt', flightDeparted: 'Departed', + flightLanded: 'Landed', flightEstimated: 'Estimated', flightOnTime: 'On time', + flightPinned: 'PINNED', flightPinnedLabel: 'Pinned', + flightNotifEnabled: 'Notifications enabled', + flightNotifPermDenied: 'Permission denied', + flightNotifPermMsg: 'Enable notifications in phone settings to use this feature.', + flightNoShift: 'No shift found', + flightNoShiftMsg: 'No "Work" shift found for today in the calendar.', + flightNotifMsg1: '{count} notifications scheduled: flight arrivals (15 min before) + end of shift.', + flightNotifMsg0: 'No future flights found, but you will receive the end-of-shift notification.', + flightNotifAccessEnable: 'Enable flight notifications', flightNotifAccessDisable: 'Disable flight notifications', + // Phonebook + phonebookTitle: 'Phonebook', contactAdd: 'Add', + contactSearch: 'Search name or number...', contactAll: 'All', + contactModalNew: 'New contact', contactModalEdit: 'Edit contact', + contactNameLabel: 'Name *', contactNamePh: 'e.g. OPS Malpensa', + contactNumberLabel: 'Number *', contactNumberPh: '+39 02 1234567', + contactCatLabel: 'Category', contactNoteLabel: 'Note (optional)', + contactNotePh: 'e.g. Office hours only, ext. 3...', + contactErrReqTitle: 'Required fields', contactErrReqMsg: 'Name and number are required.', + contactDeleteTitle: 'Delete contact', contactDeleteConfirm: 'Delete', + contactEmptyTitle: 'Empty phonebook', + contactEmptyHint: 'Tap "Add" to insert the first contact', + contactNoResults: 'No results', + // Password + passwordTitle: 'Password', passwordAdd: 'Add', + passwordModalNew: 'New entry', passwordModalEdit: 'Edit entry', + passwordNameLabel: 'Name *', passwordNamePh: 'e.g. EasyJet HR Portal', + passwordUsernameLabel: 'Username / Email', passwordUsernamePh: 'e.g. mario.rossi@easyjet.com', + passwordPwLabel: 'Password *', passwordNotesLabel: 'Notes', + passwordNotesPh: 'e.g. expires every 90 days…', + passwordErrName: 'Name is required.', + passwordErrPw: 'Password is required.', + passwordDeleteTitle: 'Delete', passwordDeleteMsg: 'Delete this entry?', + passwordEmptyTxt: 'No passwords saved.', passwordEmptySubTxt: 'Press "Add" to get started.', + pinInsert: 'Enter PIN', pinSetup: 'Set PIN (4 digits)', + pinSetTitle: 'PIN set', pinSetMsg: 'The password screen is now protected.', + pinWrong: 'Wrong PIN', pinWrongMsg: 'Try again.', + pinDisableTitle: 'Disable PIN', pinDisableMsg: 'Remove PIN protection?', + pinDisableBtn: 'Disable', pinErrMsg: 'Could not set PIN. Try again.', + pinVerifyErr: 'Could not verify PIN. Try again.', + pinAccessEnable: 'Enable PIN protection', pinAccessDisable: 'Disable PIN protection', + // DrawerMenu + drawerNotepadTitle: 'Notepad', + drawerNotepadSub: 'Personal notes', + drawerPhonebookTitle: 'Phonebook', + drawerPhonebookSub: 'Useful numbers', + drawerPasswordTitle: 'Passwords', + drawerPasswordSub: 'Saved credentials', + drawerManualsTitle: 'DCS Manuals', + drawerManualsSub: 'Document library', + drawerSettingsTitle: 'Settings', + drawerSettingsSub: 'App preferences', + // ShiftTimeline + shiftUnknown: 'Unknown', + // FlightScreen filter menu + flightFilterTitle: 'Show flights', + flightFilterMine: 'My airlines', + flightFilterMineSub: 'Wizz, EasyJet and other configured operators', + flightFilterAll: 'All flights', + flightFilterAllSub: 'Show all operators at this airport', + flightAirlineSearch: 'Search airline...', + flightAirlineEmpty: 'Open the Flights tab to load airlines', +}; + +export const translations: Record = { it, en }; +export type TranslationKey = keyof typeof it; + +export const MONTHS: Record = { + it: ['Gennaio','Febbraio','Marzo','Aprile','Maggio','Giugno','Luglio','Agosto','Settembre','Ottobre','Novembre','Dicembre'], + en: ['January','February','March','April','May','June','July','August','September','October','November','December'], +}; + +export const WEEKDAYS_SHORT: Record = { + it: ['Dom','Lun','Mar','Mer','Gio','Ven','Sab'], + en: ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'], +}; + +export const WEEKDAYS_LONG: Record = { + it: ['Domenica','Luned\u00ec','Marted\u00ec','Mercoled\u00ec','Gioved\u00ec','Venerd\u00ec','Sabato'], + en: ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'], +}; + +export const LOCALE_MAP: Record = { it: 'it-IT', en: 'en-GB' }; + +export const LANGUAGES: Array<{ code: Lang; label: string; flag: string }> = [ + { code: 'it', label: 'Italiano', flag: '\ud83c\uddee\ud83c\uddf9' }, + { code: 'en', label: 'English', flag: '\ud83c\uddec\ud83c\udde7' }, +]; + +export const WEATHER_MAP: Record> = { + it: { + 0: { text: 'Sereno', icon: '\u2600\ufe0f' }, + 1: { text: 'Poco Nuvoloso', icon: '\ud83c\udf24\ufe0f' }, + 2: { text: 'Nuvoloso', icon: '\u26c5' }, + 3: { text: 'Coperto', icon: '\u2601\ufe0f' }, + 45: { text: 'Nebbia', icon: '\ud83c\udf2b\ufe0f' }, + 61: { text: 'Pioggia Leggera', icon: '\ud83c\udf26\ufe0f' }, + 63: { text: 'Pioggia', icon: '\ud83c\udf27\ufe0f' }, + 80: { text: 'Rovesci', icon: '\ud83c\udf27\ufe0f' }, + }, + en: { + 0: { text: 'Clear', icon: '\u2600\ufe0f' }, + 1: { text: 'Mostly Clear', icon: '\ud83c\udf24\ufe0f' }, + 2: { text: 'Partly Cloudy', icon: '\u26c5' }, + 3: { text: 'Overcast', icon: '\u2601\ufe0f' }, + 45: { text: 'Foggy', icon: '\ud83c\udf2b\ufe0f' }, + 61: { text: 'Light Rain', icon: '\ud83c\udf26\ufe0f' }, + 63: { text: 'Rain', icon: '\ud83c\udf27\ufe0f' }, + 80: { text: 'Showers', icon: '\ud83c\udf27\ufe0f' }, + }, +}; diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index 1266625..9884403 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -1,810 +1,777 @@ -import React, { useState, useEffect, useRef, useMemo } from 'react'; -import { - View, Text, StyleSheet, ActivityIndicator, ScrollView, TouchableOpacity, - PanResponder, Platform, UIManager, Animated, Dimensions, Modal, Alert, FlatList, TextInput, KeyboardAvoidingView, Keyboard, -} from 'react-native'; -import * as SystemCalendar from 'expo-calendar'; -import * as Location from 'expo-location'; -import * as DocumentPicker from 'expo-document-picker'; -import * as FileSystem from 'expo-file-system/legacy'; -import { WebView } from 'react-native-webview'; -import { MaterialIcons } from '@expo/vector-icons'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { useAppTheme } from '../context/ThemeContext'; -import { useAirport } from '../context/AirportContext'; -import { fetchAirportScheduleRaw } from '../utils/fr24api'; -import { - getWritableCalendarId, - replaceShiftForDate, - replaceShiftsForRange, -} from '../utils/shiftCalendar'; -import { - getPdfExtractorHtml, parseShiftCells, - type ParsedSchedule, type ParsedEmployee, type ParsedShift, -} from '../utils/pdfShiftParser'; - -const PRIMARY = '#2563EB'; -const STORAGE_KEY = '@shift_import_name'; - -type ShiftEvent = { - id: string; - title: string; - startDate: string | Date; - endDate: string | Date; -}; - -if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) { - UIManager.setLayoutAnimationEnabledExperimental(true); -} - -const weatherMap: Record = { - 0: { text: 'Sereno', icon: '☀️' }, - 1: { text: 'Poco Nuvoloso', icon: '🌤️' }, - 2: { text: 'Nuvoloso', icon: '⛅' }, - 3: { text: 'Coperto', icon: '☁️' }, - 45: { text: 'Nebbia', icon: '🌫️' }, - 61: { text: 'Pioggia Leggera', icon: '🌦️' }, - 63: { text: 'Pioggia', icon: '🌧️' }, - 80: { text: 'Rovesci', icon: '🌧️' }, -}; - -function getMonday(d: Date | null | undefined): Date { - if (!d || isNaN(d.getTime())) return getMonday(new Date()); - const date = new Date(d); - const day = date.getDay(); - date.setDate(date.getDate() - day + (day === 0 ? -6 : 1)); - return date; -} - -export default function CalendarScreen() { - const { colors } = useAppTheme(); - const { airportCode, isLoading: airportLoading } = useAirport(); - const [currentWeekStart, setCurrentWeekStart] = useState(getMonday(new Date())); - const [selectedDay, setSelectedDay] = useState(new Date().toISOString().split('T')[0]); - const [markedDates, setMarkedDates] = useState>({}); - const [eventsData, setEventsData] = useState>({}); - const [dailyStats, setDailyStats] = useState>({}); - const [loading, setLoading] = useState(true); - const [calId, setCalId] = useState(null); - - // Import flow state - const [importModalVisible, setImportModalVisible] = useState(false); - const [importStep, setImportStep] = useState<'idle' | 'extracting' | 'pickName' | 'preview' | 'saving' | 'done'>('idle'); - const [pdfHtml, setPdfHtml] = useState(null); - const [parsedSchedule, setParsedSchedule] = useState(null); - const [selectedEmployee, setSelectedEmployee] = useState(null); - const [savedName, setSavedName] = useState(null); - - // ─── Edit menu + manual entry ─────────────────────────────────────────────── - const [editMenuOpen, setEditMenuOpen] = useState(false); - const [manualModalOpen, setManualModalOpen] = useState(false); - const [manualDate, setManualDate] = useState(selectedDay); - const [manualType, setManualType] = useState<'Lavoro' | 'Riposo'>('Lavoro'); - const [manualStartH, setManualStartH] = useState('08'); - const [manualStartM, setManualStartM] = useState('00'); - const [manualEndH, setManualEndH] = useState('16'); - const [manualEndM, setManualEndM] = useState('00'); - const manualStartMRef = useRef(null); - const manualEndHRef = useRef(null); - const manualEndMRef = useRef(null); - - const openManualEntry = () => { - setEditMenuOpen(false); - setManualDate(selectedDay); - setManualType('Lavoro'); - setManualStartH('08'); setManualStartM('00'); - setManualEndH('16'); setManualEndM('00'); - setManualModalOpen(true); - }; - - const sanitizeTimePart = (value: string) => value.replace(/\D/g, '').slice(0, 2); - - const saveManualShift = async () => { - const { status } = await SystemCalendar.requestCalendarPermissionsAsync(); - if (status !== 'granted') { Alert.alert('Permesso negato'); return; } - - try { - const calendarId = calId ?? await getWritableCalendarId(); - if (!calendarId) { Alert.alert('Errore', 'Nessun calendario scrivibile'); return; } - if (!calId) setCalId(calendarId); - - await replaceShiftForDate({ - calendarId, - date: manualDate, - type: manualType === 'Riposo' ? 'rest' : 'work', - startTime: manualType === 'Lavoro' ? `${manualStartH.padStart(2, '0')}:${manualStartM.padStart(2, '0')}` : undefined, - endTime: manualType === 'Lavoro' ? `${manualEndH.padStart(2, '0')}:${manualEndM.padStart(2, '0')}` : undefined, - }); - - setManualModalOpen(false); - fetchCalendar(true); - Alert.alert('Turno salvato!'); - } catch (e: any) { Alert.alert('Errore', e.message); } - }; - - const SCREEN_W = Dimensions.get('window').width; - const weekSlideX = useRef(new Animated.Value(0)).current; - - // Load saved name - useEffect(() => { - AsyncStorage.getItem(STORAGE_KEY).then(n => { if (n) setSavedName(n); }); - }, []); - - const changeWeek = (dir: 1 | -1) => { - Animated.timing(weekSlideX, { - toValue: dir * SCREEN_W, duration: 120, useNativeDriver: true, - }).start(() => { - setCurrentWeekStart(d => { const n = new Date(d); n.setDate(n.getDate() + dir * -7); return n; }); - weekSlideX.setValue(-dir * SCREEN_W); - Animated.timing(weekSlideX, { - toValue: 0, duration: 120, useNativeDriver: true, - }).start(); - }); - }; - - const panResponder = useRef( - PanResponder.create({ - onStartShouldSetPanResponder: () => false, - onMoveShouldSetPanResponder: (_, g) => Math.abs(g.dx) > 15, - onMoveShouldSetPanResponderCapture: (_, g) => Math.abs(g.dx) > 15 && Math.abs(g.dx) > Math.abs(g.dy) * 1.5, - onPanResponderTerminationRequest: () => false, - onPanResponderMove: (_, g) => { weekSlideX.setValue(g.dx); }, - onPanResponderRelease: (_, g) => { - if (Math.abs(g.dx) > SCREEN_W * 0.2) { - changeWeek(g.dx > 0 ? 1 : -1); - } else { - Animated.spring(weekSlideX, { - toValue: 0, useNativeDriver: true, tension: 120, friction: 10, - }).start(); - } - }, - }) - ).current; - - const getWeekDays = (start: Date) => - Array.from({ length: 7 }).map((_, i) => { - const d = new Date(start); - d.setDate(d.getDate() + i); - const iso = d.toISOString().split('T')[0]; - return { - date: d, iso, - dayNum: d.getDate(), - dayName: ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab'][d.getDay()], - }; - }); - - useEffect(() => { - if (!airportLoading) fetchCalendar(); - }, [currentWeekStart, airportCode, airportLoading]); - - const fetchCalendar = async (silent = false) => { - try { - if (!silent) setLoading(true); - const { status } = await SystemCalendar.requestCalendarPermissionsAsync(); - if (status !== 'granted') { setLoading(false); return; } - const calendars = await SystemCalendar.getCalendarsAsync(SystemCalendar.EntityTypes.EVENT); - const cal = calendars.find(c => c.allowsModifications && c.isPrimary) || calendars.find(c => c.allowsModifications); - if (!cal) { setLoading(false); return; } - setCalId(cal.id); - const start = new Date(currentWeekStart); start.setHours(0, 0, 0, 0); - const end = new Date(currentWeekStart); end.setDate(end.getDate() + 7); - const events = await SystemCalendar.getEventsAsync([cal.id], start, end); - const dots: Record = {}; - const localData: Record = {}; - events.forEach(e => { - if (e.title.includes('Lavoro') || e.title.includes('Riposo')) { - const iso = new Date(e.startDate).toISOString().split('T')[0]; - if (!localData[iso]) localData[iso] = []; - localData[iso].push({ id: e.id, title: e.title, startDate: e.startDate, endDate: e.endDate }); - // Lavoro has priority over Riposo for dot color - if (e.title.includes('Lavoro') || !dots[iso]) { - dots[iso] = e.title.includes('Riposo') ? '#10b981' : PRIMARY; - } - } - }); - setMarkedDates(dots); - setEventsData(localData); - setLoading(false); - fetchWeatherAndFlights(start, end, localData); - } catch (e) { console.error(e); setLoading(false); } - }; - - const fetchWeatherAndFlights = async (start: Date, end: Date, localData: Record) => { - const dict: Record = {}; - try { - await Location.requestForegroundPermissionsAsync(); - const loc = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced }); - const s = start.toISOString().split('T')[0]; - const e2 = new Date(end.getTime() - 1000).toISOString().split('T')[0]; - const wr = await fetch(`https://api.open-meteo.com/v1/forecast?latitude=${loc.coords.latitude}&longitude=${loc.coords.longitude}&daily=weather_code&timezone=Europe%2FRome&start_date=${s}&end_date=${e2}`); - const wj = await wr.json(); - if (wj.daily?.time) { - wj.daily.time.forEach((date: string, i: number) => { - const m = weatherMap[wj.daily.weather_code[i] || 0] || { text: 'Sereno', icon: '☀️' }; - dict[date] = { weatherText: m.text, weatherIcon: m.icon, flightCount: 0 }; - }); - } - } catch (e) { console.warn('[calWeather]', e); } - try { - const { arrivals, departures } = await fetchAirportScheduleRaw(airportCode); - const allF = [...arrivals, ...departures]; - Object.keys(localData).forEach(iso => { - const sh = localData[iso].find(e => e.title.includes('Lavoro')); - if (sh) { - const sTS = new Date(sh.startDate).getTime() / 1000; - const eTS = new Date(sh.endDate).getTime() / 1000; - const cnt = allF.filter(f => { - const ts = f.flight?.time?.scheduled?.arrival || f.flight?.time?.scheduled?.departure; - return ts && ts >= sTS && ts <= eTS; - }).length; - if (dict[iso]) dict[iso].flightCount = cnt; else dict[iso] = { weatherText: 'N/A', weatherIcon: '❓', flightCount: cnt }; - } - }); - } catch (e) { console.warn('[calFlights]', e); } - setDailyStats(dict); - }; - - // ─── Import flow ───────────────────────────────────────────────────────────── - const startImport = async () => { - let step = 'picker'; - try { - const result = await DocumentPicker.getDocumentAsync({ - type: 'application/pdf', - copyToCacheDirectory: true, - }); - if (result.canceled || !result.assets?.[0]) return; - - step = 'read'; - const uri = result.assets[0].uri; - const fileInfo = await FileSystem.getInfoAsync(uri); - if (!fileInfo.exists) { - Alert.alert('Errore', `File non trovato: ${uri}`); - return; - } - - step = 'base64'; - const base64 = await FileSystem.readAsStringAsync(uri, { encoding: FileSystem.EncodingType.Base64 }); - - step = 'webview'; - setImportStep('extracting'); - setImportModalVisible(true); - setPdfHtml(getPdfExtractorHtml(base64)); - } catch (e: any) { - console.error(`Import error at step=${step}:`, e); - Alert.alert('Errore', `Errore (${step}): ${e?.message || e}`); - } - }; - - const onWebViewMessage = (event: any) => { - try { - const data = JSON.parse(event.nativeEvent.data); - setPdfHtml(null); // Remove WebView - - if (!data.ok) { - Alert.alert('Errore', 'Impossibile estrarre il testo dal PDF'); - setImportModalVisible(false); - setImportStep('idle'); - return; - } - - const schedule = parseShiftCells(data.cells); - if (schedule.employees.length === 0) { - Alert.alert('Errore', 'Nessun dipendente trovato nel PDF'); - setImportModalVisible(false); - setImportStep('idle'); - return; - } - - setParsedSchedule(schedule); - - // Auto-select if saved name matches - if (savedName) { - const match = schedule.employees.find(e => - e.name.toLowerCase().includes(savedName.toLowerCase()) - ); - if (match) { - setSelectedEmployee(match); - setImportStep('preview'); - return; - } - } - - setImportStep('pickName'); - } catch (e) { - console.error(e); - Alert.alert('Errore', 'Errore nel parsing del PDF'); - setImportModalVisible(false); - setImportStep('idle'); - } - }; - - const selectEmployee = (emp: ParsedEmployee) => { - setSelectedEmployee(emp); - // Save name for next time - AsyncStorage.setItem(STORAGE_KEY, emp.name); - setSavedName(emp.name); - setImportStep('preview'); - }; - - const confirmImport = async () => { - if (!selectedEmployee) return; - setImportStep('saving'); - - try { - const calendarId = calId ?? await getWritableCalendarId(); - if (!calendarId) { - Alert.alert('Errore', 'Nessun calendario scrivibile'); - setImportStep('idle'); - return; - } - if (!calId) setCalId(calendarId); - - const saved = await replaceShiftsForRange({ - calendarId, - shifts: selectedEmployee.shifts.map(shift => ({ - date: shift.date, - type: shift.type, - startTime: shift.start, - endTime: shift.end, - })), - }); - - setImportStep('done'); - setTimeout(() => { - setImportModalVisible(false); - setImportStep('idle'); - fetchCalendar(true); - Alert.alert('Importazione completata', `${saved} turni salvati nel calendario`); - }, 800); - } catch (e) { - console.error(e); - Alert.alert('Errore', 'Errore durante il salvataggio'); - setImportStep('idle'); - } - }; - - const weekDays = getWeekDays(currentWeekStart); - const monthLabel = currentWeekStart.toLocaleDateString('it-IT', { month: 'long', year: 'numeric' }); - const selectedEvents = eventsData[selectedDay] || []; - const workEvent = selectedEvents.find(e => e.title.includes('Lavoro')); - const restEvent = selectedEvents.find(e => e.title.includes('Riposo')); - const stats = dailyStats[selectedDay]; - const s = useMemo(() => makeStyles(colors), [colors]); - - const fmtDate = (iso: string) => { - const [y, m, d] = iso.split('-'); - const dt = new Date(+y, +m - 1, +d); - const dayName = ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab'][dt.getDay()]; - return `${dayName} ${d}/${m}`; - }; - - return ( - - - {/* Page Header */} - - - - Gestione Turni - {monthLabel.toUpperCase()} - - setEditMenuOpen(true)}> - - Modifica Turni - - - - - {/* Week strip + contenuto animato */} - - - setCurrentWeekStart(d => { const n = new Date(d); n.setDate(n.getDate() - 7); return n; })} style={s.navBtn}> - - - {weekDays.map(day => { - const isSelected = day.iso === selectedDay; - const dotColor = markedDates[day.iso]; - return ( - setSelectedDay(day.iso)}> - - {day.dayName} - {day.dayNum} - - {dotColor && } - - ); - })} - setCurrentWeekStart(d => { const n = new Date(d); n.setDate(n.getDate() + 7); return n; })} style={s.navBtn}> - - - - - {/* Main Shift Card */} - {loading ? ( - - ) : ( - - {stats && ( - - {stats.weatherIcon} - - Meteo locale - {stats.weatherText} - - - )} - - {workEvent ? ( - <> - - ✈️ - Turno Lavoro - - - 🕒 - - {new Date(workEvent.startDate).toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' })} — {new Date(workEvent.endDate).toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' })} - - - {stats?.flightCount > 0 && ( - - ✈️ {stats.flightCount} voli nel turno - - )} - - ) : restEvent ? ( - - 🌴 - Giorno di Riposo - - ) : ( - Nessun turno per{'\n'}{selectedDay.split('-').reverse().join('/')} - )} - - )} - - - - {/* ─── Edit Menu Modal ─── */} - setEditMenuOpen(false)}> - - setEditMenuOpen(false)} /> - - Modifica Turni - { setEditMenuOpen(false); startImport(); }}> - - - Importa da PDF - Carica il foglio turni aziendale - - - - - - - Aggiungi manualmente - Seleziona giorno e orario - - - - - - - - {/* ─── Manual Entry Modal ─── */} - setManualModalOpen(false)}> - - - - - - Aggiungi Turno - setManualModalOpen(false)}> - - - - - {/* Data */} - DATA - - - Seleziona un giorno dal calendario per cambiare la data - - - {/* Tipo */} - TIPO - - {(['Lavoro', 'Riposo'] as const).map(t => ( - setManualType(t)} - > - {t === 'Lavoro' ? '✈️ Lavoro' : '🌴 Riposo'} - - ))} - - - {/* Orari (solo lavoro) */} - {manualType === 'Lavoro' && ( - <> - ORARIO INIZIO - - setManualStartH(sanitizeTimePart(v))} - selectTextOnFocus - returnKeyType="next" - blurOnSubmit={false} - onSubmitEditing={() => manualStartMRef.current?.focus()} - /> - setManualStartM(sanitizeTimePart(v))} - selectTextOnFocus - returnKeyType="next" - blurOnSubmit={false} - onSubmitEditing={() => manualEndHRef.current?.focus()} - /> - - ORARIO FINE - - setManualEndH(sanitizeTimePart(v))} - selectTextOnFocus - returnKeyType="next" - blurOnSubmit={false} - onSubmitEditing={() => manualEndMRef.current?.focus()} - /> - setManualEndM(sanitizeTimePart(v))} - selectTextOnFocus - returnKeyType="done" - onSubmitEditing={Keyboard.dismiss} - /> - - - )} - - - Salva Turno - - - - - - - {/* Hidden WebView for PDF extraction */} - {pdfHtml && ( - - )} - - {/* ─── Import Modal ─── */} - { - if (importStep !== 'saving') { setImportModalVisible(false); setImportStep('idle'); } - }}> - - - - {/* Header */} - - Importa Turni - {importStep !== 'saving' && ( - { setImportModalVisible(false); setImportStep('idle'); }}> - - - )} - - - {/* Step: Extracting */} - {importStep === 'extracting' && ( - - - Estrazione testo dal PDF... - - )} - - {/* Step: Pick name */} - {importStep === 'pickName' && parsedSchedule && ( - <> - - Seleziona il tuo nome ({parsedSchedule.employees.length} trovati) - - String(i)} - style={{ maxHeight: 400 }} - nestedScrollEnabled - renderItem={({ item }) => ( - selectEmployee(item)} - > - {item.name} - - - )} - /> - - )} - - {/* Step: Preview */} - {importStep === 'preview' && selectedEmployee && ( - <> - - Turni di {selectedEmployee.name} - - - {selectedEmployee.shifts.map((shift, i) => ( - - {fmtDate(shift.date)} - {shift.type === 'work' ? ( - - - {shift.start} - {shift.end} - - - ) : ( - - Riposo - - )} - - ))} - - - setImportStep('pickName')} - > - Cambia nome - - - Salva nel calendario - - - - )} - - {/* Step: Saving */} - {importStep === 'saving' && ( - - - Salvataggio in corso... - - )} - - {/* Step: Done */} - {importStep === 'done' && ( - - - Turni importati! - - )} - - - - - ); -} - -function makeStyles(c: any) { - return StyleSheet.create({ - pageHeader: { backgroundColor: c.card, paddingHorizontal: 16, paddingVertical: 14, borderBottomWidth: 1, borderBottomColor: c.border }, - pageTitle: { fontSize: 22, fontWeight: 'bold', color: c.primaryDark }, - pageSub: { fontSize: 11, color: c.textSub, letterSpacing: 1.5, marginTop: 3 }, - importBtn: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingHorizontal: 14, paddingVertical: 8, borderRadius: 10 }, - importBtnText: { color: '#fff', fontSize: 14, fontWeight: '600' }, - weekRow: { flexDirection: 'row', alignItems: 'center', backgroundColor: c.card, paddingVertical: 12, paddingHorizontal: 4, borderBottomWidth: 1, borderBottomColor: c.border }, - navBtn: { paddingHorizontal: 8, paddingVertical: 6 }, - navArrow: { color: c.textSub, fontSize: 13, fontWeight: 'bold' }, - dayChipWrap: { flex: 1, alignItems: 'center' }, - dayChip: { alignItems: 'center', paddingVertical: 6, paddingHorizontal: 2, borderRadius: 20, width: 36 }, - dayChipSelected: { backgroundColor: c.primary }, - dayChipName: { fontSize: 10, color: c.textSub, marginBottom: 3 }, - dayChipNameSel: { color: '#fff' }, - dayChipNum: { fontSize: 15, fontWeight: '600', color: c.text }, - dayChipNumSel: { color: '#fff', fontWeight: 'bold' }, - dot: { width: 5, height: 5, borderRadius: 3, marginTop: 3 }, - mainCard: { - backgroundColor: c.card, borderRadius: 14, - marginHorizontal: 16, marginTop: 16, - padding: 20, - shadowColor: '#000', shadowOpacity: c.isDark ? 0 : 0.07, shadowRadius: 10, elevation: c.isDark ? 0 : 4, borderWidth: c.isDark ? 1 : 0, borderColor: c.border, - minHeight: 160, - }, - weatherBadge: { - position: 'absolute', top: 14, right: 14, - flexDirection: 'row', alignItems: 'center', - backgroundColor: c.bg, borderRadius: 10, - paddingHorizontal: 10, paddingVertical: 6, gap: 6, - }, - weatherIcon: { fontSize: 18 }, - weatherPlace: { fontSize: 10, color: c.textSub, fontWeight: '600' }, - weatherText: { fontSize: 12, color: c.text, fontWeight: '600' }, - shiftTypeRow: { flexDirection: 'row', alignItems: 'center', gap: 12, marginBottom: 14, marginTop: 6 }, - shiftIconBox: { width: 44, height: 44, backgroundColor: c.primaryLight, borderRadius: 12, justifyContent: 'center', alignItems: 'center' }, - shiftTypeName: { fontSize: 19, fontWeight: 'bold', color: c.primaryDark }, - timeRow: { flexDirection: 'row', alignItems: 'center' }, - timeText: { fontSize: 22, fontWeight: 'bold', color: c.primary }, - flightBadge: { marginTop: 14, backgroundColor: c.primaryLight, borderRadius: 10, paddingHorizontal: 14, paddingVertical: 8, alignSelf: 'flex-start' }, - flightBadgeText: { color: c.primary, fontWeight: '700', fontSize: 13 }, - restRow: { flexDirection: 'row', alignItems: 'center', marginTop: 10 }, - restText: { fontSize: 20, fontWeight: 'bold', color: '#10b981' }, - emptyText: { textAlign: 'center', color: c.textSub, fontSize: 15, marginTop: 20, lineHeight: 24 }, - // Modal - modalOverlay: { flex: 1, justifyContent: 'flex-end' }, - modalBg: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' }, - modalScrollContent: { flex: 1, justifyContent: 'flex-end' }, - modalContent: { borderTopLeftRadius: 24, borderTopRightRadius: 24, padding: 24, paddingBottom: 100, maxHeight: '92%' }, - manualModalContent: { borderTopLeftRadius: 24, borderTopRightRadius: 24, padding: 24, paddingBottom: 32, maxHeight: '92%' }, - modalHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }, - modalTitle: { fontSize: 20, fontWeight: 'bold' }, - centerBox: { alignItems: 'center', paddingVertical: 40, gap: 12 }, - stepText: { fontSize: 16, fontWeight: '600' }, - stepLabel: { fontSize: 14, marginBottom: 12 }, - nameRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 14, paddingHorizontal: 12, borderBottomWidth: 1, borderRadius: 8, marginBottom: 4 }, - nameText: { fontSize: 15, fontWeight: '500' }, - previewRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 10, paddingHorizontal: 4, borderBottomWidth: 1 }, - previewDate: { fontSize: 14, fontWeight: '600' }, - previewPill: { paddingHorizontal: 12, paddingVertical: 5, borderRadius: 8 }, - previewPillText: { fontSize: 13, fontWeight: '700' }, - secondaryBtn: { flex: 1, paddingVertical: 12, borderRadius: 10, alignItems: 'center', borderWidth: 1 }, - secondaryBtnText: { fontSize: 14, fontWeight: '600' }, - primaryBtn: { flex: 2, paddingVertical: 12, borderRadius: 10, alignItems: 'center' }, - primaryBtnText: { color: '#fff', fontSize: 14, fontWeight: 'bold' }, - // Edit menu - editMenuContent: { borderTopLeftRadius: 24, borderTopRightRadius: 24, padding: 24, paddingBottom: 40 }, - editMenuOption: { flexDirection: 'row', alignItems: 'center', gap: 14, padding: 16, borderRadius: 14, marginBottom: 10 }, - editMenuLabel: { fontSize: 15, fontWeight: '600' }, - editMenuSub: { fontSize: 12, marginTop: 2 }, - // Manual entry - manualLabel: { fontSize: 11, fontWeight: '700', letterSpacing: 1, marginBottom: 6 }, - manualInput: { borderWidth: 1, borderRadius: 10, paddingHorizontal: 14, paddingVertical: 12, fontSize: 16, marginBottom: 4 }, - manualTimeRow: { flexDirection: 'row', gap: 10, marginBottom: 14 }, - manualTimeInput: { flex: 1, textAlign: 'center' }, - manualTypeBtn: { flex: 1, paddingVertical: 12, borderRadius: 10, borderWidth: 1.5, alignItems: 'center' }, - }); -} +import React, { useState, useEffect, useRef, useMemo } from 'react'; +import { + View, Text, StyleSheet, ActivityIndicator, ScrollView, TouchableOpacity, + PanResponder, Platform, UIManager, Animated, Dimensions, Modal, Alert, FlatList, TextInput, +} from 'react-native'; +import * as SystemCalendar from 'expo-calendar'; +import * as Location from 'expo-location'; +import * as DocumentPicker from 'expo-document-picker'; +import * as FileSystem from 'expo-file-system/legacy'; +import { WebView } from 'react-native-webview'; +import { MaterialIcons } from '@expo/vector-icons'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useAppTheme, type ThemeColors } from '../context/ThemeContext'; +import TimeCarouselPicker from '../components/TimeCarouselPicker'; +import { useAirport } from '../context/AirportContext'; +import { fetchAirportScheduleRaw } from '../utils/fr24api'; +import { + getWritableCalendarId, + replaceShiftForDate, + replaceShiftsForRange, +} from '../utils/shiftCalendar'; +import { + getPdfExtractorHtml, parseShiftCells, + type ParsedSchedule, type ParsedEmployee, type ParsedShift, +} from '../utils/pdfShiftParser'; +import { useLanguage } from '../context/LanguageContext'; + +const STORAGE_KEY = '@shift_import_name'; + +type ShiftEvent = { + id: string; + title: string; + startDate: string | Date; + endDate: string | Date; +}; + +if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) { + UIManager.setLayoutAnimationEnabledExperimental(true); +} + +// weatherMap comes from useLanguage() context + + +function getMonday(d: Date | null | undefined): Date { + if (!d || isNaN(d.getTime())) return getMonday(new Date()); + const date = new Date(d); + const day = date.getDay(); + date.setDate(date.getDate() - day + (day === 0 ? -6 : 1)); + return date; +} + +export default function CalendarScreen() { + const { colors } = useAppTheme(); + const { t, months, weekDaysShort, locale, weatherMap } = useLanguage(); + const { airportCode, isLoading: airportLoading } = useAirport(); + const [currentWeekStart, setCurrentWeekStart] = useState(getMonday(new Date())); + const [selectedDay, setSelectedDay] = useState(new Date().toISOString().split('T')[0]); + const [markedDates, setMarkedDates] = useState>({}); + const [eventsData, setEventsData] = useState>({}); + const [dailyStats, setDailyStats] = useState>({}); + const [loading, setLoading] = useState(true); + const [calId, setCalId] = useState(null); + + // Import flow state + const [importModalVisible, setImportModalVisible] = useState(false); + const [importStep, setImportStep] = useState<'idle' | 'extracting' | 'pickName' | 'preview' | 'saving' | 'done'>('idle'); + const [pdfHtml, setPdfHtml] = useState(null); + const [parsedSchedule, setParsedSchedule] = useState(null); + const [selectedEmployee, setSelectedEmployee] = useState(null); + const [savedName, setSavedName] = useState(null); + + // ─── Edit menu + manual entry ─────────────────────────────────────────────── + const [editMenuOpen, setEditMenuOpen] = useState(false); + const [manualModalOpen, setManualModalOpen] = useState(false); + const [pickerKey, setPickerKey] = useState(0); + const [manualDate, setManualDate] = useState(selectedDay); + const [manualType, setManualType] = useState<'Lavoro' | 'Riposo'>('Lavoro'); + const [manualStartH, setManualStartH] = useState(8); + const [manualStartM, setManualStartM] = useState(0); + const [manualEndH, setManualEndH] = useState(16); + const [manualEndM, setManualEndM] = useState(0); + + const openManualEntry = () => { + setEditMenuOpen(false); + setManualDate(selectedDay); + setManualType('Lavoro'); + setManualStartH(8); setManualStartM(0); + setManualEndH(16); setManualEndM(0); + setPickerKey(k => k + 1); + setManualModalOpen(true); + }; + + + const saveManualShift = async () => { + const { status } = await SystemCalendar.requestCalendarPermissionsAsync(); + if (status !== 'granted') { Alert.alert(t('calPermDenied')); return; } + + try { + const calendarId = calId ?? await getWritableCalendarId(); + if (!calendarId) { Alert.alert('Errore', t('calNoWritableCalendar')); return; } + if (!calId) setCalId(calendarId); + + await replaceShiftForDate({ + calendarId, + date: manualDate, + type: manualType === 'Riposo' ? 'rest' : 'work', + startTime: manualType === 'Lavoro' ? `${String(manualStartH).padStart(2, '0')}:${String(manualStartM).padStart(2, '0')}` : undefined, + endTime: manualType === 'Lavoro' ? `${String(manualEndH).padStart(2, '0')}:${String(manualEndM).padStart(2, '0')}` : undefined, + }); + + setManualModalOpen(false); + fetchCalendar(true); + Alert.alert(t('calShiftSaved')); + } catch (e: any) { Alert.alert('Errore', e.message); } + }; + + const SCREEN_W = Dimensions.get('window').width; + const weekSlideX = useRef(new Animated.Value(0)).current; + + // Load saved name + useEffect(() => { + AsyncStorage.getItem(STORAGE_KEY).then(n => { if (n) setSavedName(n); }); + }, []); + + const changeWeek = (dir: 1 | -1) => { + Animated.timing(weekSlideX, { + toValue: dir * SCREEN_W, duration: 120, useNativeDriver: true, + }).start(() => { + setCurrentWeekStart(d => { const n = new Date(d); n.setDate(n.getDate() + dir * -7); return n; }); + weekSlideX.setValue(-dir * SCREEN_W); + Animated.timing(weekSlideX, { + toValue: 0, duration: 120, useNativeDriver: true, + }).start(); + }); + }; + + const panResponder = useRef( + PanResponder.create({ + onStartShouldSetPanResponder: () => false, + onMoveShouldSetPanResponder: (_, g) => Math.abs(g.dx) > 15, + onMoveShouldSetPanResponderCapture: (_, g) => Math.abs(g.dx) > 15 && Math.abs(g.dx) > Math.abs(g.dy) * 1.5, + onPanResponderTerminationRequest: () => false, + onPanResponderMove: (_, g) => { weekSlideX.setValue(g.dx); }, + onPanResponderRelease: (_, g) => { + if (Math.abs(g.dx) > SCREEN_W * 0.2) { + changeWeek(g.dx > 0 ? 1 : -1); + } else { + Animated.spring(weekSlideX, { + toValue: 0, useNativeDriver: true, tension: 120, friction: 10, + }).start(); + } + }, + }) + ).current; + + const getWeekDays = (start: Date) => + Array.from({ length: 7 }).map((_, i) => { + const d = new Date(start); + d.setDate(d.getDate() + i); + const iso = d.toISOString().split('T')[0]; + return { + date: d, iso, + dayNum: d.getDate(), + dayName: weekDaysShort[d.getDay()], + }; + }); + + useEffect(() => { + if (!airportLoading) fetchCalendar(); + }, [currentWeekStart, airportCode, airportLoading]); + + const fetchCalendar = async (silent = false) => { + try { + if (!silent) setLoading(true); + const { status } = await SystemCalendar.requestCalendarPermissionsAsync(); + if (status !== 'granted') { setLoading(false); return; } + const calendars = await SystemCalendar.getCalendarsAsync(SystemCalendar.EntityTypes.EVENT); + const cal = calendars.find(c => c.allowsModifications && c.isPrimary) || calendars.find(c => c.allowsModifications); + if (!cal) { setLoading(false); return; } + setCalId(cal.id); + const start = new Date(currentWeekStart); start.setHours(0, 0, 0, 0); + const end = new Date(currentWeekStart); end.setDate(end.getDate() + 7); + const events = await SystemCalendar.getEventsAsync([cal.id], start, end); + const dots: Record = {}; + const localData: Record = {}; + events.forEach(e => { + if (e.title.includes('Lavoro') || e.title.includes('Riposo')) { + const iso = new Date(e.startDate).toISOString().split('T')[0]; + if (!localData[iso]) localData[iso] = []; + localData[iso].push({ id: e.id, title: e.title, startDate: e.startDate, endDate: e.endDate }); + // Lavoro has priority over Riposo for dot color + if (e.title.includes('Lavoro') || !dots[iso]) { + dots[iso] = e.title.includes('Riposo') ? '#10b981' : colors.primary; + } + } + }); + setMarkedDates(dots); + setEventsData(localData); + setLoading(false); + fetchWeatherAndFlights(start, end, localData); + } catch (e) { if (__DEV__) console.error(e); setLoading(false); } + }; + + const fetchWeatherAndFlights = async (start: Date, end: Date, localData: Record) => { + const dict: Record = {}; + try { + await Location.requestForegroundPermissionsAsync(); + const loc = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced }); + const s = start.toISOString().split('T')[0]; + const e2 = new Date(end.getTime() - 1000).toISOString().split('T')[0]; + const wr = await fetch(`https://api.open-meteo.com/v1/forecast?latitude=${loc.coords.latitude}&longitude=${loc.coords.longitude}&daily=weather_code&timezone=Europe%2FRome&start_date=${s}&end_date=${e2}`); + const wj = await wr.json(); + if (wj.daily?.time) { + wj.daily.time.forEach((date: string, i: number) => { + const m = weatherMap[wj.daily.weather_code[i] || 0] || { text: 'Sereno', icon: '☀️' }; + dict[date] = { weatherText: m.text, weatherIcon: m.icon, flightCount: 0 }; + }); + } + } catch (e) { if (__DEV__) console.warn('[calWeather]', e); } + try { + const { arrivals, departures } = await fetchAirportScheduleRaw(airportCode); + const allF = [...arrivals, ...departures]; + Object.keys(localData).forEach(iso => { + const sh = localData[iso].find(e => e.title.includes('Lavoro')); + if (sh) { + const sTS = new Date(sh.startDate).getTime() / 1000; + const eTS = new Date(sh.endDate).getTime() / 1000; + const cnt = allF.filter(f => { + const ts = f.flight?.time?.scheduled?.arrival || f.flight?.time?.scheduled?.departure; + return ts && ts >= sTS && ts <= eTS; + }).length; + if (dict[iso]) dict[iso].flightCount = cnt; else dict[iso] = { weatherText: 'N/A', weatherIcon: '❓', flightCount: cnt }; + } + }); + } catch (e) { if (__DEV__) console.warn('[calFlights]', e); } + setDailyStats(dict); + }; + + // ─── Import flow ───────────────────────────────────────────────────────────── + const startImport = async () => { + let step = 'picker'; + try { + const result = await DocumentPicker.getDocumentAsync({ + type: 'application/pdf', + copyToCacheDirectory: true, + }); + if (result.canceled || !result.assets?.[0]) return; + + step = 'read'; + const uri = result.assets[0].uri; + const fileInfo = await FileSystem.getInfoAsync(uri); + if (!fileInfo.exists) { + Alert.alert('Errore', `File non trovato: ${uri}`); + return; + } + + step = 'base64'; + const base64 = await FileSystem.readAsStringAsync(uri, { encoding: FileSystem.EncodingType.Base64 }); + + step = 'webview'; + setImportStep('extracting'); + setImportModalVisible(true); + setPdfHtml(getPdfExtractorHtml(base64)); + } catch (e: any) { + if (__DEV__) console.error(`Import error at step=${step}:`, e); + Alert.alert('Errore', `Errore (${step}): ${e?.message || e}`); + } + }; + + const onWebViewMessage = (event: any) => { + try { + const data = JSON.parse(event.nativeEvent.data); + setPdfHtml(null); // Remove WebView + + if (!data.ok) { + Alert.alert('Errore', t('calNoPdfText')); + setImportModalVisible(false); + setImportStep('idle'); + return; + } + + const schedule = parseShiftCells(data.cells); + if (schedule.employees.length === 0) { + Alert.alert('Errore', t('calNoEmployees')); + setImportModalVisible(false); + setImportStep('idle'); + return; + } + + setParsedSchedule(schedule); + + // Auto-select if saved name matches + if (savedName) { + const match = schedule.employees.find(e => + e.name.toLowerCase().includes(savedName.toLowerCase()) + ); + if (match) { + setSelectedEmployee(match); + setImportStep('preview'); + return; + } + } + + setImportStep('pickName'); + } catch (e) { + if (__DEV__) console.error(e); + Alert.alert('Errore', 'Errore nel parsing del PDF'); + setImportModalVisible(false); + setImportStep('idle'); + } + }; + + const selectEmployee = (emp: ParsedEmployee) => { + setSelectedEmployee(emp); + // Save name for next time + AsyncStorage.setItem(STORAGE_KEY, emp.name); + setSavedName(emp.name); + setImportStep('preview'); + }; + + const confirmImport = async () => { + if (!selectedEmployee) return; + setImportStep('saving'); + + try { + const calendarId = calId ?? await getWritableCalendarId(); + if (!calendarId) { + Alert.alert('Errore', 'Nessun calendario scrivibile'); + setImportStep('idle'); + return; + } + if (!calId) setCalId(calendarId); + + const saved = await replaceShiftsForRange({ + calendarId, + shifts: selectedEmployee.shifts.map(shift => ({ + date: shift.date, + type: shift.type, + startTime: shift.start, + endTime: shift.end, + })), + }); + + setImportStep('done'); + setTimeout(() => { + setImportModalVisible(false); + setImportStep('idle'); + fetchCalendar(true); + Alert.alert(t('calImportComplete'), `${saved} turni salvati nel calendario`); + }, 800); + } catch (e) { + if (__DEV__) console.error(e); + Alert.alert('Errore', t('calImportError')); + setImportStep('idle'); + } + }; + + const weekDays = getWeekDays(currentWeekStart); + const monthLabel = currentWeekStart.toLocaleDateString(locale, { month: 'long', year: 'numeric' }); + const selectedEvents = eventsData[selectedDay] || []; + const workEvent = selectedEvents.find(e => e.title.includes('Lavoro')); + const restEvent = selectedEvents.find(e => e.title.includes('Riposo')); + const stats = dailyStats[selectedDay]; + const s = useMemo(() => makeStyles(colors), [colors]); + + const fmtDate = (iso: string) => { + const [y, m, d] = iso.split('-'); + const dt = new Date(+y, +m - 1, +d); + const dayName = ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab'][dt.getDay()]; + return `${dayName} ${d}/${m}`; + }; + + return ( + + + {/* Page Header */} + + + + {t('calTitle')} + {monthLabel.toUpperCase()} + + setEditMenuOpen(true)}> + + {t('calEditBtn')} + + + + + {/* Week strip + contenuto animato */} + + + setCurrentWeekStart(d => { const n = new Date(d); n.setDate(n.getDate() - 7); return n; })} style={s.navBtn}> + + + {weekDays.map(day => { + const isSelected = day.iso === selectedDay; + const dotColor = markedDates[day.iso]; + return ( + setSelectedDay(day.iso)}> + + {day.dayName} + {day.dayNum} + + {dotColor && } + + ); + })} + setCurrentWeekStart(d => { const n = new Date(d); n.setDate(n.getDate() + 7); return n; })} style={s.navBtn}> + + + + + {/* Main Shift Card */} + {loading ? ( + + ) : ( + + {stats && ( + + {stats.weatherIcon} + + {t('calWeatherLocal')} + {stats.weatherText} + + + )} + + {workEvent ? ( + <> + + ✈️ + {t('calShiftWork')} + + + 🕒 + + {new Date(workEvent.startDate).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })} — {new Date(workEvent.endDate).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })} + + + {stats?.flightCount > 0 && ( + + ✈️ {stats.flightCount} voli nel turno + + )} + + ) : restEvent ? ( + + 🌴 + {t('calRestDay')} + + ) : ( + + + + + {t('calNoShift')} + {selectedDay.split('-').reverse().join('/')} + + )} + + )} + + + + {/* ─── Edit Menu Modal ─── */} + setEditMenuOpen(false)}> + + setEditMenuOpen(false)} /> + + Modifica Turni + { setEditMenuOpen(false); startImport(); }}> + + + {t('calImportPdf')} + {t('calImportPdfSub')} + + + + + + + {t('calAddManual')} + {t('calAddManualSub')} + + + + + + + + {/* ─── Manual Entry Modal ─── */} + setManualModalOpen(false)}> + + setManualModalOpen(false)} /> + + + + {t('calAddShiftTitle')} + setManualModalOpen(false)}> + + + + + + {/* Data */} + {t('calDataLabel')} + + + Seleziona un giorno dal calendario per cambiare la data + + + {/* Tipo */} + {t('calTypeLabel')} + + {(['Lavoro', 'Riposo'] as const).map(shiftType => ( + setManualType(shiftType)} + > + {shiftType === 'Lavoro' ? t('calTypeWork') : t('calTypeRest')} + + ))} + + + {/* Orari (solo lavoro) */} + {manualType === 'Lavoro' && ( + <> + {t('calStartTime')} + + {t('calEndTime')} + + + )} + + + {t('calSaveShift')} + + + + + + + + {/* Hidden WebView for PDF extraction */} + {pdfHtml && ( + + )} + + {/* ─── Import Modal ─── */} + { + if (importStep !== 'saving') { setImportModalVisible(false); setImportStep('idle'); } + }}> + + + + {/* Header */} + + {t('calImportTitle')} + {importStep !== 'saving' && ( + { setImportModalVisible(false); setImportStep('idle'); }}> + + + )} + + + {/* Step: Extracting */} + {importStep === 'extracting' && ( + + + {t('calExtracting')} + + )} + + {/* Step: Pick name */} + {importStep === 'pickName' && parsedSchedule && ( + <> + + {t('calPickName')} ({parsedSchedule.employees.length} trovati) + + String(i)} + style={{ maxHeight: 400 }} + nestedScrollEnabled + renderItem={({ item }) => ( + selectEmployee(item)} + > + {item.name} + + + )} + /> + + )} + + {/* Step: Preview */} + {importStep === 'preview' && selectedEmployee && ( + <> + + {t('calShiftsOf')} {selectedEmployee.name} + + + {selectedEmployee.shifts.map((shift, i) => ( + + {fmtDate(shift.date)} + {shift.type === 'work' ? ( + + + {shift.start} - {shift.end} + + + ) : ( + + {t('calRestPill')} + + )} + + ))} + + + setImportStep('pickName')} + > + {t('calChangeName')} + + + {t('calSaveToCalendar')} + + + + )} + + {/* Step: Saving */} + {importStep === 'saving' && ( + + + {t('calSaving')} + + )} + + {/* Step: Done */} + {importStep === 'done' && ( + + + {t('calImportDone')} + + )} + + + + + ); +} + +function makeStyles(c: ThemeColors) { + return StyleSheet.create({ + pageHeader: { backgroundColor: c.card, paddingHorizontal: 16, paddingTop: 16, paddingBottom: 14, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: c.border }, + pageTitle: { fontSize: 22, fontWeight: 'bold', color: c.primaryDark }, + pageSub: { fontSize: 11, color: c.textSub, letterSpacing: 1.5, marginTop: 3 }, + importBtn: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingHorizontal: 14, paddingVertical: 8, borderRadius: 10 }, + importBtnText: { color: '#fff', fontSize: 14, fontWeight: '600' }, + weekRow: { flexDirection: 'row', alignItems: 'center', backgroundColor: c.card, paddingVertical: 12, paddingHorizontal: 4, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: c.border }, + navBtn: { paddingHorizontal: 6, paddingVertical: 8, minWidth: 28, alignItems: 'center' as const }, + navArrow: { color: c.textSub, fontSize: 13, fontWeight: 'bold' }, + dayChipWrap: { flex: 1, alignItems: 'center' }, + dayChip: { alignItems: 'center', paddingVertical: 8, paddingHorizontal: 4, borderRadius: 22, width: 42, minHeight: 52 }, + dayChipSelected: { backgroundColor: c.primary }, + dayChipName: { fontSize: 11, color: c.textSub, marginBottom: 4, fontWeight: '500', letterSpacing: 0.3 }, + dayChipNameSel: { color: '#fff' }, + dayChipNum: { fontSize: 16, fontWeight: '700', color: c.text }, + dayChipNumSel: { color: '#fff', fontWeight: 'bold' }, + dot: { width: 6, height: 6, borderRadius: 3, marginTop: 4 }, + mainCard: { + backgroundColor: c.card, borderRadius: 14, + marginHorizontal: 16, marginTop: 16, + padding: 20, + shadowColor: c.primary, shadowOpacity: c.isDark ? 0 : 0.08, shadowRadius: 10, elevation: c.isDark ? 0 : 4, borderWidth: c.isDark ? 1 : 0, borderColor: c.glassBorder, + minHeight: 160, + }, + weatherBadge: { + position: 'absolute', top: 14, right: 14, + flexDirection: 'row', alignItems: 'center', + backgroundColor: c.bg, borderRadius: 10, + paddingHorizontal: 10, paddingVertical: 6, gap: 6, + }, + weatherIcon: { fontSize: 18 }, + weatherPlace: { fontSize: 10, color: c.textSub, fontWeight: '600' }, + weatherText: { fontSize: 12, color: c.text, fontWeight: '600' }, + shiftTypeRow: { flexDirection: 'row', alignItems: 'center', gap: 12, marginBottom: 14, marginTop: 6 }, + shiftIconBox: { width: 44, height: 44, backgroundColor: c.primaryLight, borderRadius: 12, justifyContent: 'center', alignItems: 'center' }, + shiftTypeName: { fontSize: 19, fontWeight: 'bold', color: c.primaryDark }, + timeRow: { flexDirection: 'row', alignItems: 'center' }, + timeText: { fontSize: 22, fontWeight: 'bold', color: c.primary }, + flightBadge: { marginTop: 14, backgroundColor: c.primaryLight, borderRadius: 10, paddingHorizontal: 14, paddingVertical: 8, alignSelf: 'flex-start' }, + flightBadgeText: { color: c.primary, fontWeight: '700', fontSize: 13 }, + restRow: { flexDirection: 'row', alignItems: 'center', marginTop: 10 }, + restText: { fontSize: 20, fontWeight: 'bold', color: '#10b981' }, + emptyText: { textAlign: 'center', color: c.textSub, fontSize: 15, marginTop: 20, lineHeight: 24 }, + emptyWrap: { alignItems: 'center', justifyContent: 'center', paddingVertical: 16 }, + emptyIconCircle: { width: 56, height: 56, borderRadius: 28, backgroundColor: c.primaryLight, justifyContent: 'center', alignItems: 'center', marginBottom: 12 }, + emptyTitle: { fontSize: 16, fontWeight: '700', color: c.text, textAlign: 'center', marginBottom: 4 }, + emptyDate: { fontSize: 13, fontWeight: '600', color: c.textSub, textAlign: 'center' }, + // Modal + modalOverlay: { flex: 1, justifyContent: 'flex-end' }, + modalBg: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' }, + modalScrollContent: { flex: 1, justifyContent: 'flex-end' }, + modalContent: { borderTopLeftRadius: 24, borderTopRightRadius: 24, padding: 24, paddingBottom: 100, maxHeight: '92%' }, + manualModalContent: { borderTopLeftRadius: 24, borderTopRightRadius: 24, padding: 24, paddingBottom: 32, maxHeight: '92%' }, + modalHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }, + modalTitle: { fontSize: 20, fontWeight: 'bold' }, + centerBox: { alignItems: 'center', paddingVertical: 40, gap: 12 }, + stepText: { fontSize: 16, fontWeight: '600' }, + stepLabel: { fontSize: 14, marginBottom: 12 }, + nameRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 14, paddingHorizontal: 12, borderBottomWidth: 1, borderRadius: 8, marginBottom: 4 }, + nameText: { fontSize: 15, fontWeight: '500' }, + previewRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 10, paddingHorizontal: 4, borderBottomWidth: 1 }, + previewDate: { fontSize: 14, fontWeight: '600' }, + previewPill: { paddingHorizontal: 12, paddingVertical: 5, borderRadius: 8 }, + previewPillText: { fontSize: 13, fontWeight: '700' }, + secondaryBtn: { flex: 1, paddingVertical: 12, borderRadius: 10, alignItems: 'center', borderWidth: 1 }, + secondaryBtnText: { fontSize: 14, fontWeight: '600' }, + primaryBtn: { flex: 2, paddingVertical: 12, borderRadius: 10, alignItems: 'center' }, + primaryBtnText: { color: '#fff', fontSize: 14, fontWeight: 'bold' }, + // Edit menu + editMenuContent: { borderTopLeftRadius: 24, borderTopRightRadius: 24, padding: 24, paddingBottom: 40 }, + editMenuOption: { flexDirection: 'row', alignItems: 'center', gap: 14, padding: 16, borderRadius: 14, marginBottom: 10 }, + editMenuLabel: { fontSize: 15, fontWeight: '600' }, + editMenuSub: { fontSize: 12, marginTop: 2 }, + // Manual entry + manualLabel: { fontSize: 11, fontWeight: '700', letterSpacing: 1, marginBottom: 6 }, + manualInput: { borderWidth: 1, borderRadius: 10, paddingHorizontal: 14, paddingVertical: 12, fontSize: 16, marginBottom: 4 }, + manualTimeRow: { flexDirection: 'row', gap: 10, marginBottom: 14 }, + manualTimeInput: { flex: 1, textAlign: 'center' }, + manualTypeBtn: { flex: 1, paddingVertical: 12, borderRadius: 10, borderWidth: 1.5, alignItems: 'center' }, + }); +} + diff --git a/src/screens/FlightScreen.tsx b/src/screens/FlightScreen.tsx index 155fb94..7bbd30e 100644 --- a/src/screens/FlightScreen.tsx +++ b/src/screens/FlightScreen.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { - View, Text, StyleSheet, ActivityIndicator, + View, Text, StyleSheet, ActivityIndicator, Modal, TextInput, ScrollView, FlatList, TouchableOpacity, RefreshControl, Image, Alert, Animated, PanResponder, NativeModules, Platform, } from 'react-native'; @@ -8,15 +8,17 @@ import * as Calendar from 'expo-calendar'; import * as Notifications from 'expo-notifications'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { MaterialIcons } from '@expo/vector-icons'; -import { useAppTheme } from '../context/ThemeContext'; +import { useAppTheme, type ThemeColors } from '../context/ThemeContext'; import { useAirport } from '../context/AirportContext'; -import { getAirlineOps, getAirlineColor } from '../utils/airlineOps'; +import { getAirlineOps, getAirlineColor, getSelectedAirlines, setSelectedAirlines } from '../utils/airlineOps'; import { fetchAirportScheduleRaw } from '../utils/fr24api'; +import { fetchStaffMonitorData, normalizeFlightNumber, type StaffMonitorFlight } from '../utils/staffMonitor'; import { formatAirportHeader } from '../utils/airportSettings'; import { requestWidgetUpdate } from 'react-native-android-widget'; import { WIDGET_CACHE_KEY } from '../widgets/widgetTaskHandler'; import type { WidgetData, WidgetFlight } from '../widgets/widgetTaskHandler'; import { ShiftWidget } from '../widgets/ShiftWidget'; +import { useLanguage } from '../context/LanguageContext'; const WearDataSender = Platform.OS === 'android' ? NativeModules.WearDataSender : null; @@ -24,6 +26,7 @@ const NOTIF_IDS_KEY = 'aerostaff_notif_ids_v1'; const NOTIF_ENABLED_KEY = 'aerostaff_notif_enabled'; const PINNED_FLIGHT_KEY = 'pinned_flight_v1'; const PINNED_NOTIF_IDS_KEY = 'pinned_notif_ids_v1'; +const FLIGHT_FILTER_KEY = 'aerostaff_flight_filter_v1'; // Handler: mostra notifiche anche con app aperta (wrapped for Expo Go compat) try { Notifications.setNotificationHandler({ @@ -34,7 +37,7 @@ try { Notifications.setNotificationHandler({ shouldShowBanner: true, shouldShowList: true, }), -}); } catch (e) { console.warn('[notifHandler]', e); } +}); } catch (e) { if (__DEV__) console.warn('[notifHandler]', e); } function LogoPill({ iataCode, airlineName, color }: { iataCode: string; airlineName: string; color: string }) { @@ -110,6 +113,7 @@ async function cancelPreviousNotifications() { async function scheduleShiftNotifications( shiftFlights: any[], shiftEnd: number, + locale: string, ): Promise { await cancelPreviousNotifications(); const now = Date.now() / 1000; @@ -126,7 +130,7 @@ async function scheduleShiftNotifications( const origin = item.flight?.airport?.origin?.name || item.flight?.airport?.origin?.code?.iata || 'N/A'; - const arrivalTime = new Date(ts * 1000).toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }); + const arrivalTime = new Date(ts * 1000).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }); const id = await Notifications.scheduleNotificationAsync({ content: { @@ -143,7 +147,7 @@ async function scheduleShiftNotifications( // Notifica fine turno const secondsUntilEnd = shiftEnd - now; if (secondsUntilEnd > 0) { - const endTime = new Date(shiftEnd * 1000).toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }); + const endTime = new Date(shiftEnd * 1000).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }); const endId = await Notifications.scheduleNotificationAsync({ content: { title: '🏁 Turno terminato', @@ -168,7 +172,7 @@ async function cancelPinnedNotifications() { await AsyncStorage.removeItem(PINNED_NOTIF_IDS_KEY); } -async function schedulePinnedNotifications(item: any, tab: 'arrivals' | 'departures'): Promise { +async function schedulePinnedNotifications(item: any, tab: 'arrivals' | 'departures', locale: string): Promise { await cancelPinnedNotifications(); const now = Date.now() / 1000; const ids: string[] = []; @@ -180,7 +184,7 @@ async function schedulePinnedNotifications(item: any, tab: 'arrivals' | 'departu const ts = item.flight?.time?.scheduled?.arrival; if (!ts) return; const origin = item.flight?.airport?.origin?.name || item.flight?.airport?.origin?.code?.iata || 'N/A'; - const arrTime = new Date(ts * 1000).toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }); + const arrTime = new Date(ts * 1000).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }); const secsUntil = ts - 15 * 60 - now; if (secsUntil > 0) { const id = await Notifications.scheduleNotificationAsync({ @@ -198,7 +202,7 @@ async function schedulePinnedNotifications(item: any, tab: 'arrivals' | 'departu const ts = item.flight?.time?.scheduled?.departure; if (!ts) return; const dest = item.flight?.airport?.destination?.name || item.flight?.airport?.destination?.code?.iata || 'N/A'; - const depTime = new Date(ts * 1000).toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }); + const depTime = new Date(ts * 1000).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }); const ops = getAirlineOps(airline); const phases: Array<{ offset: number; title: string; body: string }> = [ @@ -232,6 +236,7 @@ async function schedulePinnedNotifications(item: any, tab: 'arrivals' | 'departu // ─── Screen ──────────────────────────────────────────────────────────────────── export default function FlightScreen() { const { colors } = useAppTheme(); + const { t, locale } = useLanguage(); const { airport, airportCode, isLoading: airportLoading } = useAirport(); const s = useMemo(() => makeStyles(colors), [colors]); const [loading, setLoading] = useState(true); @@ -245,25 +250,74 @@ export default function FlightScreen() { const [scheduledCount, setScheduledCount] = useState(0); const [pinnedFlightId, setPinnedFlightId] = useState(null); const [inboundArrivals, setInboundArrivals] = useState>({}); + const [filterMode, setFilterMode] = useState<'mine' | 'all'>('mine'); + const [filterMenuVisible, setFilterMenuVisible] = useState(false); + const [selectedAirlines, setSelectedAirlinesState] = useState([]); + const [airlineSearchText, setAirlineSearchText] = useState(''); + const [allArrivalsFull, setAllArrivalsFull] = useState([]); + const [allDeparturesFull, setAllDeparturesFull] = useState([]); + const [staffMonitorDeps, setStaffMonitorDeps] = useState([]); + const [staffMonitorArrs, setStaffMonitorArrs] = useState([]); - // Carica preferenza notifiche salvata + // Accumulated flights seen today — persist until end of day even if FR24 drops them + const seenArrivalsRef = useRef>(new Map()); + const seenDeparturesRef = useRef>(new Map()); + const seenAllArrivalsRef = useRef>(new Map()); + const seenAllDeparturesRef = useRef>(new Map()); + const seenDateRef = useRef(new Date().toDateString()); + + // Carica preferenze salvate useEffect(() => { AsyncStorage.getItem(NOTIF_ENABLED_KEY).then(v => setNotifsEnabled(v === 'true')); + AsyncStorage.getItem(FLIGHT_FILTER_KEY).then(v => { if (v === 'all' || v === 'mine') setFilterMode(v); }); }, []); + // Reload airline selection when airport changes + useEffect(() => { + if (airportCode) getSelectedAirlines(airportCode).then(setSelectedAirlinesState); + }, [airportCode]); + const fetchAll = useCallback(async () => { if (airportLoading) return; try { const { allArrivals, + allDepartures, departures: fetchedDepartures, arrivals: fetchedArrivals, } = await fetchAirportScheduleRaw(airportCode); + // Reset accumulated cache at midnight + const todayStr = new Date().toDateString(); + if (seenDateRef.current !== todayStr) { + seenArrivalsRef.current.clear(); + seenDeparturesRef.current.clear(); + seenAllArrivalsRef.current.clear(); + seenAllDeparturesRef.current.clear(); + seenDateRef.current = todayStr; + } + + // Merge fresh data into accumulated maps (fresh wins, old entries persist) + const mergeFlights = (seen: Map, fresh: any[]) => { + for (const f of fresh) { + const id = f.flight?.identification?.number?.default; + if (id) seen.set(id, f); + } + return Array.from(seen.values()); + }; + + const mergedArrivals = mergeFlights(seenArrivalsRef.current, fetchedArrivals); + const mergedDepartures = mergeFlights(seenDeparturesRef.current, fetchedDepartures); + const mergedAllArrivals = mergeFlights(seenAllArrivalsRef.current, allArrivals); + const mergedAllDepartures = mergeFlights(seenAllDeparturesRef.current, allDepartures); + + setAllArrivalsFull(mergedAllArrivals); + setAllDeparturesFull(mergedAllDepartures); + // Build inbound arrival map: registration → best known arrival timestamp const inboundMap: Record = {}; - for (const a of allArrivals) { + for (const a of mergedAllArrivals) { const reg = a.flight?.aircraft?.registration; if (!reg) continue; const t = a.flight?.time?.real?.arrival @@ -273,22 +327,22 @@ export default function FlightScreen() { } setInboundArrivals(inboundMap); - setArrivals(fetchedArrivals); - setDepartures(fetchedDepartures); + setArrivals(mergedArrivals); + setDepartures(mergedDepartures); - // Auto-clear expired pinned flight or stale data from another airport + // Auto-clear pinned flight only at end of day or if from another airport const pinnedRaw = await AsyncStorage.getItem(PINNED_FLIGHT_KEY); if (pinnedRaw) { try { const pinned = JSON.parse(pinnedRaw); - const pinTab = pinned._pinTab || 'departures'; - const pinTs = pinTab === 'arrivals' + const pinTs = (pinned._pinTab || 'departures') === 'arrivals' ? pinned.flight?.time?.scheduled?.arrival : pinned.flight?.time?.scheduled?.departure; - const pinId = pinned.flight?.identification?.number?.default; - const pool = pinTab === 'arrivals' ? fetchedArrivals : fetchedDepartures; - const stillPresent = !!pinId && pool.some(item => item.flight?.identification?.number?.default === pinId); - if ((pinTs && pinTs < Date.now() / 1000) || !stillPresent) { + // Expire at end of the flight's scheduled day, not at departure/arrival time + const endOfFlightDay = pinTs + ? new Date(pinTs * 1000).setHours(23, 59, 59, 999) / 1000 + : 0; + if (endOfFlightDay && Date.now() / 1000 > endOfFlightDay) { await AsyncStorage.removeItem(PINNED_FLIGHT_KEY); await cancelPinnedNotifications(); setPinnedFlightId(null); @@ -329,7 +383,7 @@ export default function FlightScreen() { // ── Push data to widget cache ── try { - const fmtT = (ts: number) => new Date(ts * 1000).toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }); + const fmtT = (ts: number) => new Date(ts * 1000).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }); const fmtOff = (dep: number, off: number) => fmtT(dep - off * 60); const nowHH = fmtT(Date.now() / 1000); @@ -345,7 +399,7 @@ export default function FlightScreen() { if (pinnedRawW) { try { pinnedFn = JSON.parse(pinnedRawW).flight?.identification?.number?.default || null; } catch {} } - const wFlights: WidgetFlight[] = fetchedDepartures + const wFlights: WidgetFlight[] = mergedDepartures .filter(item => { const ts = item.flight?.time?.scheduled?.departure; if (ts == null) return false; @@ -356,7 +410,7 @@ export default function FlightScreen() { return (ciO <= shiftToday!.end && ciC >= shiftToday!.start) || (gO <= shiftToday!.end && gC >= shiftToday!.start); }) .map(item => { - const ts = item.flight.time.scheduled.departure; + const ts = item.flight?.time?.scheduled?.departure ?? 0; const airline = item.flight?.airline?.name || 'Sconosciuta'; const ops = getAirlineOps(airline); const fn = item.flight?.identification?.number?.default || 'N/A'; @@ -386,17 +440,17 @@ export default function FlightScreen() { // Schedula notifiche se attive (solo turno di oggi) const enabled = (await AsyncStorage.getItem(NOTIF_ENABLED_KEY)) === 'true'; if (enabled && shiftToday) { - const shiftFlights = fetchedArrivals.filter(item => { + const shiftFlights = mergedArrivals.filter(item => { const ts = item.flight?.time?.scheduled?.arrival; return ts && ts >= shiftToday!.start && ts <= shiftToday!.end; }); - const count = await scheduleShiftNotifications(shiftFlights, shiftToday!.end); + const count = await scheduleShiftNotifications(shiftFlights, shiftToday!.end, locale); setScheduledCount(count); } else { await cancelPreviousNotifications(); setScheduledCount(0); } - } catch (e) { console.error('[fetchAll]', e); } finally { setLoading(false); setRefreshing(false); } + } catch (e) { if (__DEV__) console.error('[fetchAll]', e); } finally { setLoading(false); setRefreshing(false); } }, [airportCode, airportLoading]); useEffect(() => { @@ -416,11 +470,28 @@ export default function FlightScreen() { }); }, []); + // staffMonitor: poll stand / gate / belt every 60 s + useEffect(() => { + const load = async () => { + try { + const [deps, arrs] = await Promise.all([ + fetchStaffMonitorData('D'), + fetchStaffMonitorData('A'), + ]); + setStaffMonitorDeps(deps); + setStaffMonitorArrs(arrs); + } catch {} + }; + load(); + const iv = setInterval(load, 60_000); + return () => clearInterval(iv); + }, []); + // Toggle notifiche const toggleNotifications = useCallback(async () => { const { status } = await Notifications.requestPermissionsAsync(); if (status !== 'granted') { - Alert.alert('Permesso negato', 'Abilita le notifiche nelle impostazioni del telefono per usare questa funzione.'); + Alert.alert(t('flightNotifPermDenied'), t('flightNotifPermMsg')); return; } const next = !notifsEnabled; @@ -439,16 +510,16 @@ export default function FlightScreen() { const ts = item.flight?.time?.scheduled?.arrival; return ts && ts >= shifts.today!.start && ts <= shifts.today!.end; }); - const count = await scheduleShiftNotifications(shiftFlights, shifts.today!.end); + const count = await scheduleShiftNotifications(shiftFlights, shifts.today!.end, locale); setScheduledCount(count); Alert.alert( - 'Notifiche attivate', + t('flightNotifEnabled'), count > 0 - ? `Programmate ${count} notifiche: arrivi voli (15 min prima) + fine turno.` - : 'Nessun volo futuro trovato, ma riceverai la notifica di fine turno.', + ? `${t('flightNotifMsg1').replace('{count}', String(count))}` + : t('flightNotifMsg0'), ); } else { - Alert.alert('Nessun turno trovato', 'Non ho trovato un turno "Lavoro" per oggi nel calendario.'); + Alert.alert(t('flightNoShift'), t('flightNoShiftMsg')); setNotifsEnabled(false); await AsyncStorage.setItem(NOTIF_ENABLED_KEY, 'false'); } @@ -461,7 +532,7 @@ export default function FlightScreen() { const tab = activeTab; await AsyncStorage.setItem(PINNED_FLIGHT_KEY, JSON.stringify({ ...item, _pinTab: tab, _pinnedAt: Date.now() })); setPinnedFlightId(id); - try { await schedulePinnedNotifications(item, tab); } catch (e) { console.warn('[pinnedNotif]', e); } + try { await schedulePinnedNotifications(item, tab, locale); } catch (e) { if (__DEV__) console.warn('[pinnedNotif]', e); } // Send to watch if (WearDataSender) { const payload = JSON.stringify({ @@ -488,21 +559,57 @@ export default function FlightScreen() { const unpinFlight = useCallback(async () => { try { await AsyncStorage.removeItem(PINNED_FLIGHT_KEY); - try { await cancelPinnedNotifications(); } catch (e) { console.warn('[cancelPinNotif]', e); } + try { await cancelPinnedNotifications(); } catch (e) { if (__DEV__) console.warn('[cancelPinNotif]', e); } setPinnedFlightId(null); if (WearDataSender) WearDataSender.clearPinnedFlight(); - } catch (e) { console.error('[unpin]', e); } + } catch (e) { if (__DEV__) console.error('[unpin]', e); } }, []); + // Build unique sorted airline list from all flights at this airport + const airportAirlines = useMemo(() => { + const map = new Map(); + for (const f of [...allArrivalsFull, ...allDeparturesFull]) { + const name = f.flight?.airline?.name; + if (!name) continue; + const key = name.toLowerCase(); + if (!map.has(key)) map.set(key, { name, iata: f.flight?.airline?.code?.iata || '' }); + } + return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name)); + }, [allArrivalsFull, allDeparturesFull]); + + const isAirlineSelected = useCallback((airlineName: string) => { + const full = airlineName.toLowerCase(); + return selectedAirlines.some(key => full.includes(key)); + }, [selectedAirlines]); + + const toggleAirline = useCallback((airlineName: string) => { + const full = airlineName.toLowerCase(); + setSelectedAirlinesState(prev => { + const matchIdx = prev.findIndex(key => full.includes(key)); + const next = matchIdx >= 0 + ? prev.filter((_, i) => i !== matchIdx) + : [...prev, full]; + setSelectedAirlines(next, airportCode); + return next; + }); + }, [airportCode]); + const userShift = activeDay === 'today' ? shifts.today : shifts.tomorrow; const selectedDate = activeDay === 'today' ? new Date() : (() => { const d = new Date(); d.setDate(d.getDate() + 1); return d; })(); const isSameDay = (d1: Date, d2: Date) => d1.getFullYear() === d2.getFullYear() && d1.getMonth() === d2.getMonth() && d1.getDate() === d2.getDate(); - const currentData = (activeTab === 'arrivals' ? arrivals : departures).filter(item => { - const ts = activeTab === 'arrivals' ? item.flight?.time?.scheduled?.arrival : item.flight?.time?.scheduled?.departure; - return ts && isSameDay(new Date(ts * 1000), selectedDate); - }); + const currentData = (() => { + const source = filterMode === 'all' + ? (activeTab === 'arrivals' ? allArrivalsFull : allDeparturesFull) + : (activeTab === 'arrivals' ? arrivals : departures); + return source.filter(item => { + const ts = activeTab === 'arrivals' + ? item.flight?.time?.scheduled?.arrival + : item.flight?.time?.scheduled?.departure; + return ts && isSameDay(new Date(ts * 1000), selectedDate); + }); + })(); const renderFlight = useCallback(({ item }: { item: any }) => { const flightNumber = item.flight?.identification?.number?.default || 'N/A'; @@ -515,7 +622,7 @@ export default function FlightScreen() { ? (item.flight?.airport?.origin?.name || item.flight?.airport?.origin?.code?.iata || 'N/A') : (item.flight?.airport?.destination?.name || item.flight?.airport?.destination?.code?.iata || 'N/A'); const ts = activeTab === 'arrivals' ? item.flight?.time?.scheduled?.arrival : item.flight?.time?.scheduled?.departure; - const time = ts ? new Date(ts * 1000).toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }) : 'N/A'; + const time = ts ? new Date(ts * 1000).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }) : 'N/A'; const duringShift = userShift && ts && (() => { if (activeTab === 'arrivals') return ts >= userShift.start && ts <= userShift.end; // Departures: CI or Gate window overlaps with shift (even 1 min) @@ -532,9 +639,9 @@ export default function FlightScreen() { // ops is null when ts is falsy — fmt is only called when ops is truthy const ops = activeTab === 'departures' && ts ? getAirlineOps(airline) : null; const fmt = (offsetMin: number) => - ts ? new Date((ts - offsetMin * 60) * 1000).toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }) : ''; + ts ? new Date((ts - offsetMin * 60) * 1000).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }) : ''; const fmtTs = (t: number) => - new Date(t * 1000).toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }); + new Date(t * 1000).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }); // Gate open = inbound aircraft arrival time (if available) const reg = item.flight?.aircraft?.registration; @@ -544,13 +651,24 @@ export default function FlightScreen() { const flightId = item.flight?.identification?.number?.default || null; const isPinned = flightId !== null && flightId === pinnedFlightId; + const normFn = normalizeFlightNumber(flightNumber); + const normalizeForMatching = (s: string) => s.replace(/[\s\-_]/g, '').toUpperCase(); + const normFnStripped = normalizeForMatching(normFn); + const smPool = activeTab === 'departures' ? staffMonitorDeps : staffMonitorArrs; + const smFlight = + smPool.find(sm => sm.flightNumber === normFn) ?? + smPool.find(sm => normalizeForMatching(sm.flightNumber) === normFnStripped); + if (__DEV__ && !smFlight && smPool.length > 0) { + console.log(`[FlightScreen] No staffMonitor match for "${normFn}" (stripped: "${normFnStripped}") in ${activeTab}`); + } + return ( isPinned ? unpinFlight() : pinFlight(item)} > - {isPinned && PINNATO} + {isPinned && {t('flightPinned')}} {/* Header */} @@ -572,14 +690,14 @@ export default function FlightScreen() { - Check-in + {t('flightCheckin')} {fmt(ops.checkInOpen)} – {fmt(ops.checkInClose)} - Gate + {t('flightGate')} {gateOpenFromInbound ? fmtTs(gateOpenFromInbound) : fmt(ops.gateOpen)} – {fmt(ops.gateClose)} @@ -600,12 +718,12 @@ export default function FlightScreen() { : delayMin > 20 ? '#EF4444' : delayMin > 5 ? '#F59E0B' : colors.primary; - const landLabel = landed ? 'Atterrato' : 'Stimato'; + const landLabel = landed ? t('flightLanded') : t('flightEstimated'); // Delay pill const delayText = landed ? 'Atterrato' : delayMin > 0 ? `+${delayMin} min` - : 'In orario'; + : t('flightOnTime'); const delayColor = landed ? '#10B981' : delayMin > 20 ? '#EF4444' : delayMin > 5 ? '#F59E0B' @@ -616,7 +734,7 @@ export default function FlightScreen() { - Partito + {t('flightDeparted')} {departed ? fmtTs(realDep) : '--:--'} @@ -654,18 +772,60 @@ export default function FlightScreen() { )} + {smFlight && (smFlight.stand || smFlight.checkin || smFlight.gate || smFlight.belt) && ( + + {smFlight.stand && ( + + + Stand {smFlight.stand} + + )} + {smFlight.checkin && ( + + + {t('flightCheckin')} {smFlight.checkin} + + )} + {smFlight.gate && ( + + + {t('flightGate')} {smFlight.gate} + + )} + {smFlight.belt && ( + + + {t('flightBelt')} {smFlight.belt} + + )} + + )} ); - }, [activeTab, userShift, s, pinnedFlightId, pinFlight, unpinFlight, inboundArrivals, colors]); + }, [activeTab, userShift, s, pinnedFlightId, pinFlight, unpinFlight, inboundArrivals, colors, staffMonitorDeps, staffMonitorArrs]); return ( {/* Page header */} - Voli in tempo reale + {t('flightTitle')} {formatAirportHeader(airport.code)} + setFilterMenuVisible(true)} + activeOpacity={0.8} + accessibilityLabel={t('flightFilterTitle')} + accessibilityRole="button" + > + + {filterMode === 'mine' && selectedAirlines.length > 0 && ( + + {selectedAirlines.length} + + )} + {/* Arrivi / Partenze */} - {(['arrivals', 'departures'] as const).map(t => ( - setActiveTab(t)}> - {t === 'arrivals' ? 'Arrivi' : 'Partenze'} + {(['arrivals', 'departures'] as const).map(tab => ( + setActiveTab(tab)}> + {tab === 'arrivals' ? t('flightArrivals') : t('flightDepartures')} ))} @@ -701,7 +861,7 @@ export default function FlightScreen() { {(['today', 'tomorrow'] as const).map(d => ( setActiveDay(d)}> - {d === 'today' ? 'Oggi' : 'Domani'} + {d === 'today' ? t('flightToday') : t('flightTomorrow')} ))} @@ -718,30 +878,129 @@ export default function FlightScreen() { renderItem={renderFlight} contentContainerStyle={{ padding: 16, paddingBottom: 96 }} refreshControl={ { setRefreshing(true); fetchAll(); }} tintColor={colors.primary} />} - ListEmptyComponent={Nessun volo per questo giorno.} + ListEmptyComponent={ + + + + + {t('flightNoFlights')} + + {activeTab === 'arrivals' ? 'Nessun arrivo previsto per questo giorno' : 'Nessuna partenza prevista per questo giorno'} + + + } showsVerticalScrollIndicator={false} /> )} + + {/* Airline Selector Modal */} + { setFilterMenuVisible(false); setAirlineSearchText(''); }} + > + + { setFilterMenuVisible(false); setAirlineSearchText(''); }} /> + + + {t('flightFilterTitle')} + + {/* Mode toggle: mine / all */} + + {(['mine', 'all'] as const).map(mode => ( + { setFilterMode(mode); AsyncStorage.setItem(FLIGHT_FILTER_KEY, mode); }} + activeOpacity={0.8} + > + + {mode === 'mine' ? t('flightFilterMine') : t('flightFilterAll')} + + + ))} + + + {/* Airline search + list (only when mode=mine) */} + {filterMode === 'mine' && ( + <> + + + {airportAirlines + .filter(a => !airlineSearchText || a.name.toLowerCase().includes(airlineSearchText.toLowerCase())) + .map(a => { + const isSelected = isAirlineSelected(a.name); + return ( + toggleAirline(a.name)} + activeOpacity={0.7} + > + + + {a.iata || a.name.slice(0, 2).toUpperCase()} + + + + {a.name} + + + + ); + })} + {airportAirlines.length === 0 && ( + + {t('flightAirlineEmpty')} + + )} + + + )} + + {/* Done button */} + { setFilterMenuVisible(false); setAirlineSearchText(''); fetchAll(); }} + activeOpacity={0.8} + > + {t('ok')} + + + + ); } -function makeStyles(c: any) { +function makeStyles(c: ThemeColors) { return StyleSheet.create({ - pageHeader: { backgroundColor: c.card, paddingHorizontal: 16, paddingVertical: 14, borderBottomWidth: 1, borderBottomColor: c.border, flexDirection: 'row', alignItems: 'center' }, + pageHeader: { backgroundColor: c.card, paddingHorizontal: 16, paddingTop: 16, paddingBottom: 14, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: c.border, flexDirection: 'row', alignItems: 'center' }, notifBtn: { width: 42, height: 42, borderRadius: 21, backgroundColor: c.cardSecondary, justifyContent: 'center', alignItems: 'center' }, notifBtnActive: { backgroundColor: c.primary, shadowColor: c.primary, shadowOffset: { width: 0, height: 3 }, shadowOpacity: 0.35, shadowRadius: 6, elevation: 5 }, notifBadge: { position: 'absolute', top: -2, right: -2, width: 16, height: 16, borderRadius: 8, backgroundColor: '#EF4444', justifyContent: 'center', alignItems: 'center', borderWidth: 1.5, borderColor: c.card }, notifBadgeTxt: { fontSize: 9, fontWeight: '800', color: '#fff' }, pageTitle: { fontSize: 22, fontWeight: 'bold', color: c.primaryDark }, pageSub: { fontSize: 13, color: c.textSub, marginTop: 2 }, - controlsRow: { flexDirection: 'row', gap: 8, padding: 12, backgroundColor: c.card, borderBottomWidth: 1, borderBottomColor: c.border }, - segment: { flex: 1, flexDirection: 'row', backgroundColor: c.bg, borderRadius: 8, padding: 3 }, - segBtn: { flex: 1, paddingVertical: 7, alignItems: 'center', borderRadius: 6 }, - segBtnActive: { backgroundColor: c.card, borderWidth: 1, borderColor: c.primaryLight }, - segBtnText: { fontSize: 12, fontWeight: '500', color: c.textSub }, + controlsRow: { flexDirection: 'row', gap: 10, paddingHorizontal: 16, paddingVertical: 10, backgroundColor: c.card, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: c.border }, + segment: { flex: 1, flexDirection: 'row', backgroundColor: c.bg, borderRadius: 10, padding: 3 }, + segBtn: { flex: 1, paddingVertical: 8, alignItems: 'center', borderRadius: 8 }, + segBtnActive: { backgroundColor: c.card, borderWidth: 1, borderColor: c.primaryLight, shadowColor: c.primary, shadowOpacity: 0.1, shadowRadius: 4, shadowOffset: { width: 0, height: 1 }, elevation: 2 }, + segBtnText: { fontSize: 13, fontWeight: '600', color: c.textSub }, segBtnTextActive: { color: c.primary, fontWeight: '700' }, - card: { backgroundColor: c.card, borderRadius: 14, marginBottom: 10, overflow: 'hidden', shadowColor: '#000', shadowOpacity: c.isDark ? 0 : 0.06, shadowRadius: 8, elevation: c.isDark ? 0 : 3, borderWidth: c.isDark ? 1 : 0, borderColor: c.border }, + card: { backgroundColor: c.card, borderRadius: 16, marginBottom: 10, overflow: 'hidden', shadowColor: c.primary, shadowOpacity: c.isDark ? 0 : 0.08, shadowRadius: 10, elevation: c.isDark ? 0 : 3, borderWidth: c.isDark ? 1 : 0, borderColor: c.glassBorder }, cardShift: { borderWidth: 1.5, borderColor: '#F59E0B' }, shiftBanner: { backgroundColor: '#F59E0B', paddingVertical: 5, paddingHorizontal: 12 }, shiftBannerText: { color: '#fff', fontWeight: 'bold', fontSize: 11, letterSpacing: 0.5 }, @@ -766,5 +1025,29 @@ function makeStyles(c: any) { opsTime: { fontSize: 13, fontWeight: '800', color: c.primaryDark }, pinBtn: { width: 34, height: 34, borderRadius: 17, backgroundColor: 'rgba(255,255,255,0.15)', justifyContent: 'center', alignItems: 'center' }, pinBtnActive: { backgroundColor: 'rgba(245,158,11,0.25)' }, + filterBtn: { width: 42, height: 42, borderRadius: 21, backgroundColor: c.cardSecondary, justifyContent: 'center', alignItems: 'center', marginRight: 8 }, + filterBtnActive: { backgroundColor: c.primary, shadowColor: c.primary, shadowOffset: { width: 0, height: 3 }, shadowOpacity: 0.35, shadowRadius: 6, elevation: 5 }, + modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.55)', justifyContent: 'flex-end' }, + filterSheet: { backgroundColor: c.card, borderTopLeftRadius: 24, borderTopRightRadius: 24, padding: 20, paddingBottom: 36 }, + filterSheetHandle: { width: 36, height: 4, borderRadius: 2, backgroundColor: c.border, alignSelf: 'center', marginBottom: 16 }, + filterSheetTitle: { fontSize: 16, fontWeight: '700', color: c.text, marginBottom: 16, textAlign: 'center' }, + filterOption: { flexDirection: 'row', alignItems: 'center', gap: 12, padding: 14, borderRadius: 14, marginBottom: 8, backgroundColor: c.bg }, + filterOptionActive: { backgroundColor: c.primaryLight, borderWidth: 1.5, borderColor: c.primaryLight }, + filterOptionText: { fontSize: 15, fontWeight: '600', color: c.text }, + filterOptionSub: { fontSize: 12, color: c.textSub, marginTop: 2 }, + smFooter: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, paddingHorizontal: 14, paddingBottom: 10, backgroundColor: c.card }, + smPill: { flexDirection: 'row', alignItems: 'center', gap: 4, backgroundColor: c.primaryLight, borderRadius: 8, paddingHorizontal: 8, paddingVertical: 4 }, + smPillText: { fontSize: 11, fontWeight: '700', color: c.primaryDark }, + // Airline selector modal + airlineSheet: { backgroundColor: c.card, borderTopLeftRadius: 24, borderTopRightRadius: 24, padding: 20, paddingBottom: 36, maxHeight: '80%' }, + modeChip: { flex: 1, paddingVertical: 10, borderRadius: 12, backgroundColor: c.bg, alignItems: 'center' }, + modeChipActive: { backgroundColor: c.primary }, + modeChipText: { fontSize: 14, fontWeight: '600', color: c.textSub }, + modeChipTextActive: { fontSize: 14, fontWeight: '700', color: '#fff' }, + airlineSearch: { backgroundColor: c.bg, borderRadius: 12, paddingHorizontal: 14, paddingVertical: 10, fontSize: 14, color: c.text, marginBottom: 10, borderWidth: 1, borderColor: c.border }, + airlineRow: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 10, paddingHorizontal: 12, borderRadius: 12, marginBottom: 4, backgroundColor: c.bg }, + airlineRowActive: { backgroundColor: c.primaryLight }, + airlineLogoPill: { width: 36, height: 24, borderRadius: 6, justifyContent: 'center', alignItems: 'center' }, + airlineRowText: { flex: 1, fontSize: 14, fontWeight: '600', color: c.text }, }); } diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 466c5ef..c3a4a9e 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -1,63 +1,27 @@ -import React, { useState, useRef, useEffect, useMemo } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { View, Text, StyleSheet, ScrollView, TouchableOpacity, - ActivityIndicator, Alert, Image, Modal, TextInput, + ActivityIndicator, Platform, UIManager } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { MaterialIcons } from '@expo/vector-icons'; -import { WebView } from 'react-native-webview'; -import * as ImagePicker from 'expo-image-picker'; import * as Calendar from 'expo-calendar'; import * as Location from 'expo-location'; -import { useAppTheme } from '../context/ThemeContext'; +import { useAppTheme, type ThemeColors } from '../context/ThemeContext'; import ShiftTimeline from '../components/ShiftTimeline'; - import { getAirlineOps, getAirlineColor } from '../utils/airlineOps'; -import { - getWritableCalendarId, - replaceShiftForDate, - replaceShiftsForRange, -} from '../utils/shiftCalendar'; - -const GOLD = '#F59E0B'; +import { getWritableCalendarId } from '../utils/shiftCalendar'; +import { useLanguage } from '../context/LanguageContext'; if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) { UIManager.setLayoutAnimationEnabledExperimental(true); } const PINNED_FLIGHT_KEY = 'pinned_flight_v1'; -const HOME_SHIFT_TITLES = { work: 'Turno Lavoro ✈️', rest: '🌴 Riposo' }; -const HOME_REST_TIMING = { startHour: 12, startMinute: 0, endHour: 14, endMinute: 0, allDay: true }; - -const weatherMap: Record = { - 0: { text: 'Sereno', icon: '☀️' }, 1: { text: 'Poco Nuvoloso', icon: '🌤️' }, - 2: { text: 'Nuvoloso', icon: '⛅' }, 3: { text: 'Coperto', icon: '☁️' }, - 45: { text: 'Nebbia', icon: '🌫️' }, 61: { text: 'Pioggia Leggera', icon: '🌦️' }, - 63: { text: 'Pioggia', icon: '🌧️' }, 80: { text: 'Rovesci', icon: '🌧️' }, -}; - -const MONTHS_IT = ['Gennaio','Febbraio','Marzo','Aprile','Maggio','Giugno','Luglio','Agosto','Settembre','Ottobre','Novembre','Dicembre']; - -const engineHtml = ` - -`; function PinnedFlightCard({ item, colors }: { item: any; colors: any }) { + const { t, locale } = useLanguage(); const tab = item._pinTab || 'departures'; const flightNumber = item.flight?.identification?.number?.default || 'N/A'; const airline = item.flight?.airline?.name || 'Sconosciuta'; @@ -72,18 +36,18 @@ function PinnedFlightCard({ item, colors }: { item: any; colors: any }) { const ts = tab === 'arrivals' ? item.flight?.time?.scheduled?.arrival : item.flight?.time?.scheduled?.departure; - const depTime = ts ? new Date(ts * 1000).toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }) : 'N/A'; + const depTime = ts ? new Date(ts * 1000).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }) : 'N/A'; const ops = getAirlineOps(airline); const fmt = (offsetMin: number) => - ts ? new Date((ts - offsetMin * 60) * 1000).toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }) : ''; + ts ? new Date((ts - offsetMin * 60) * 1000).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }) : ''; return ( {/* Compact header: airline color bar + flight info */} @@ -98,7 +62,7 @@ function PinnedFlightCard({ item, colors }: { item: any; colors: any }) { {airline} - {tab === 'arrivals' ? 'Arrivo' : 'Partenza'} + {tab === 'arrivals' ? t('homeArrival') : t('homeDeparture')} @@ -111,14 +75,14 @@ function PinnedFlightCard({ item, colors }: { item: any; colors: any }) { {tab === 'departures' ? ( - + CHECK-IN {fmt(ops.checkInOpen)} – {fmt(ops.checkInClose)} - + GATE @@ -138,7 +102,7 @@ function PinnedFlightCard({ item, colors }: { item: any; colors: any }) { - Pinnato + {t('homePinned')} @@ -148,26 +112,13 @@ function PinnedFlightCard({ item, colors }: { item: any; colors: any }) { export default function HomeScreen() { const { colors } = useAppTheme(); + const { t, months, locale, weatherMap } = useLanguage(); const today = new Date(); const [shiftEvent, setShiftEvent] = useState(null); const [weather, setWeather] = useState<{ text: string; icon: string; temp: number } | null>(null); const [loadingShift, setLoadingShift] = useState(true); - const [uploadOpen, setUploadOpen] = useState(false); - const [imageList, setImageList] = useState([]); - const [ocrText, setOcrText] = useState(''); - const [processing, setProcessing] = useState(false); - - const [shiftModalOpen, setShiftModalOpen] = useState(false); - const [newShiftType, setNewShiftType] = useState<'Lavoro' | 'Riposo'>('Lavoro'); - const [newStartH, setNewStartH] = useState('08'); - const [newStartM, setNewStartM] = useState('00'); - const [newEndH, setNewEndH] = useState('16'); - const [newEndM, setNewEndM] = useState('00'); - const [uploadMode, setUploadMode] = useState<'image' | 'manual' | null>(null); const [pinnedFlight, setPinnedFlight] = useState(null); - const webViewRef = useRef(null); - useEffect(() => { fetchShift(); }, []); useEffect(() => { fetchWeather(); }, []); @@ -195,65 +146,20 @@ export default function HomeScreen() { return () => clearInterval(interval); }, []); - const openModifyModal = () => { - if (shiftEvent) { - setNewShiftType(isRest ? 'Riposo' : 'Lavoro'); - const start = new Date(shiftEvent.startDate); - const end = new Date(shiftEvent.endDate); - setNewStartH(start.getHours().toString().padStart(2, '0')); - setNewStartM(start.getMinutes().toString().padStart(2, '0')); - setNewEndH(end.getHours().toString().padStart(2, '0')); - setNewEndM(end.getMinutes().toString().padStart(2, '0')); - } else { - setNewShiftType('Lavoro'); - setNewStartH('08'); setNewStartM('00'); setNewEndH('16'); setNewEndM('00'); - } - setShiftModalOpen(true); - }; - - const saveManualShift = async () => { - const { status } = await Calendar.requestCalendarPermissionsAsync(); - if (status !== 'granted') { Alert.alert('Permesso negato', 'Autorizza il calendario.'); return; } - try { - const calendarId = await getWritableCalendarId(); - if (!calendarId) { Alert.alert('Errore', 'Nessun calendario scrivibile.'); return; } - - const todayDate = new Date(); - const y = todayDate.getFullYear(); - const m = todayDate.getMonth() + 1; - const d = todayDate.getDate(); - const date = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`; - - await replaceShiftForDate({ - calendarId, - date, - type: newShiftType === 'Riposo' ? 'rest' : 'work', - startTime: newShiftType === 'Lavoro' ? `${newStartH.padStart(2, '0')}:${newStartM.padStart(2, '0')}` : undefined, - endTime: newShiftType === 'Lavoro' ? `${newEndH.padStart(2, '0')}:${newEndM.padStart(2, '0')}` : undefined, - titles: HOME_SHIFT_TITLES, - restTiming: HOME_REST_TIMING, - }); - - setShiftModalOpen(false); - fetchShift(true); - } catch (e: any) { Alert.alert('Errore', e.message); } - }; - const fetchShift = async (silent = false) => { if (!silent) setLoadingShift(true); try { const { status } = await Calendar.requestCalendarPermissionsAsync(); if (status !== 'granted') { setLoadingShift(false); return; } - const cals = await Calendar.getCalendarsAsync(Calendar.EntityTypes.EVENT); - const cal = cals.find(c => c.allowsModifications && c.isPrimary) || cals.find(c => c.allowsModifications); - if (!cal) { setLoadingShift(false); return; } + const calId = await getWritableCalendarId(); + if (!calId) { setLoadingShift(false); return; } const d = new Date(); d.setHours(0, 0, 0, 0); const dEnd = new Date(); dEnd.setHours(23, 59, 59, 999); - const events = await Calendar.getEventsAsync([cal.id], d, dEnd); + const events = await Calendar.getEventsAsync([calId], d, dEnd); const shift = events.find(e => e.title.includes('Lavoro') || e.title.includes('Riposo')); setShiftEvent(shift || null); - } catch (e) { console.error('[shift]', e); } finally { setLoadingShift(false); } + } catch (e) { if (__DEV__) console.error('[shift]', e); } finally { setLoadingShift(false); } }; const fetchWeather = async () => { @@ -267,87 +173,7 @@ export default function HomeScreen() { const temp = Math.round(json.current?.temperature_2m ?? 0); const w = weatherMap[code] || { text: 'Sereno', icon: '☀️' }; setWeather({ ...w, temp }); - } catch (e) { console.warn('[weather]', e); } - }; - - const pickImage = async () => { - try { - const result = await ImagePicker.launchImageLibraryAsync({ - mediaTypes: ImagePicker.MediaTypeOptions.Images, - allowsMultipleSelection: true, quality: 1, base64: true, - }); - if (!result.canceled && result.assets?.length > 0) { - setImageList(result.assets.map(a => a.uri)); - setProcessing(true); setOcrText(''); - const base64List = result.assets.map(a => `data:image/jpeg;base64,${a.base64}`); - const base64Json = JSON.stringify(base64List); - // Use postMessage pattern to avoid script-injection risks with injectJavaScript - webViewRef.current?.injectJavaScript(` - if(window.runTesseract){ - window.runTesseract(${JSON.stringify(base64Json)}); - } else { - window.ReactNativeWebView.postMessage(JSON.stringify({success:false,error:'OCR non pronto'})); - } - true; - `); - } - } catch (e) { console.error('[imagePicker]', e); setProcessing(false); } - }; - - const handleWebViewMessage = (event: any) => { - try { - const r = JSON.parse(event.nativeEvent.data); - if (r.success) setOcrText(r.text); - else Alert.alert('Errore riconoscimento testo', r.error || 'Prova con un\'immagine più nitida o meglio illuminata.'); - } catch (e) { console.error('[ocrMessage]', e); } finally { setProcessing(false); } - }; - - const parseAndSave = async () => { - const { status } = await Calendar.requestCalendarPermissionsAsync(); - if (status !== 'granted') { Alert.alert('Permesso negato', 'Autorizza il calendario.'); return; } - try { - const calendarId = await getWritableCalendarId(); - if (!calendarId) { Alert.alert('Errore', 'Nessun calendario scrivibile.'); return; } - const norText = ocrText.replace(/[OoQ]/g, '0').replace(/[Il|]/g, '1'); - const dateRegex = /\b(\d{2})[\/\-](\d{2})[\/\-](\d{4})\b/g; - const dates: any[] = []; let m; - while ((m = dateRegex.exec(norText)) !== null) dates.push({ day: +m[1], month: +m[2]-1, year: +m[3], raw: m[0] }); - const safeText = norText.replace(/\b20\d{2}\b/g, ' ANNO '); - const shiftRegex = /\b([01]?\d|2\d)[.,:]?(\d{2})\s*[-–—_~|]+\s*([01]?\d|2\d)[.,:]?(\d{2})\b|\b(R|RIP|RIP0S0|R1P0S0|R1POSO)\b/g; - const shifts: any[] = []; - while ((m = shiftRegex.exec(safeText)) !== null) { - if (m[5]) shifts.push({ isRest: true, raw: m[0] }); - else shifts.push({ isRest: false, startH: +m[1], startM: +m[2], endH: +m[3], endM: +m[4], raw: m[0] }); - } - - const parsedShifts = []; - for (let i = 0; i < Math.min(dates.length, shifts.length); i++) { - const d = dates[i]; - const s = shifts[i]; - const date = `${d.year}-${String(d.month + 1).padStart(2, '0')}-${String(d.day).padStart(2, '0')}`; - - if (s.isRest) { - parsedShifts.push({ date, type: 'rest' as const }); - } else { - parsedShifts.push({ - date, - type: 'work' as const, - startTime: `${String(s.startH).padStart(2, '0')}:${String(s.startM).padStart(2, '0')}`, - endTime: `${String(s.endH).padStart(2, '0')}:${String(s.endM).padStart(2, '0')}`, - }); - } - } - - const saved = await replaceShiftsForRange({ - calendarId, - shifts: parsedShifts, - titles: HOME_SHIFT_TITLES, - restTiming: HOME_REST_TIMING, - }); - - Alert.alert(saved > 0 ? '✅ Turni Sincronizzati!' : 'Nessun orario trovato', saved > 0 ? `${saved} turni salvati.` : `Date: ${dates.length}, Orari: ${shifts.length}`); - if (saved > 0) fetchShift(true); - } catch (e: any) { Alert.alert('Errore Calendario', e.message); } + } catch (e) { if (__DEV__) console.warn('[weather]', e); } }; const isRest = shiftEvent?.title?.includes('Riposo'); @@ -356,11 +182,6 @@ export default function HomeScreen() { return ( - {/* Hidden OCR WebView */} - - - - {/* Top cards row: Weather + Date */} @@ -368,16 +189,16 @@ export default function HomeScreen() { <> {weather.icon} {weather.temp}° - Meteo locale • {weather.text} + {t('homeWeatherLocal')} • {weather.text} ) : ( )} - OGGI + {t('homeToday')} {today.getDate()} - {MONTHS_IT[today.getMonth()]} + {months[today.getMonth()]} @@ -385,7 +206,7 @@ export default function HomeScreen() { {pinnedFlight && } {/* Turno Attuale */} - Turno Attuale + {t('homeCurrentShift')} {loadingShift ? ( @@ -395,21 +216,27 @@ export default function HomeScreen() { - IN CORSO + {t('homeInProgress')} - Turno Lavoro ✈️ + {t('homeShiftWork')} - {new Date(shiftEvent.startDate).toLocaleTimeString('it-IT',{hour:'2-digit',minute:'2-digit'})} – {new Date(shiftEvent.endDate).toLocaleTimeString('it-IT',{hour:'2-digit',minute:'2-digit'})} + {new Date(shiftEvent.startDate).toLocaleTimeString(locale,{hour:'2-digit',minute:'2-digit'})} – {new Date(shiftEvent.endDate).toLocaleTimeString(locale,{hour:'2-digit',minute:'2-digit'})} ) : isRest ? ( 🌴 - Giorno di Riposo + {t('homeRestDay')} ) : ( - Nessun turno per oggi + + + + + {t('homeNoShift')} + Controlla la pagina Turni per aggiornamenti + )} @@ -425,56 +252,36 @@ export default function HomeScreen() { /> )} + ); } -function makeStyles(c: any) { +function makeStyles(c: ThemeColors) { return StyleSheet.create({ - hiddenWV: { height: 1, width: 1, opacity: 0, position: 'absolute', top: -100 }, topRow: { flexDirection: 'row', gap: 12, padding: 16, paddingBottom: 8 }, - weatherCard: { flex: 1, backgroundColor: c.card, borderRadius: 14, padding: 16, alignItems: 'center', shadowColor: '#000', shadowOpacity: c.isDark ? 0 : 0.06, shadowRadius: 8, elevation: c.isDark ? 0 : 3, borderWidth: c.isDark ? 1 : 0, borderColor: c.border }, + weatherCard: { flex: 1, backgroundColor: c.card, borderRadius: 18, padding: 16, alignItems: 'center', shadowColor: c.isDark ? '#000000' : c.primary, shadowOpacity: c.isDark ? 0 : 0.15, shadowRadius: 16, shadowOffset: { width: 0, height: 4 }, elevation: c.isDark ? 0 : 6, borderWidth: 1, borderColor: c.glassBorder }, weatherEmoji: { fontSize: 28, marginBottom: 4 }, - weatherTemp: { fontSize: 28, fontWeight: 'bold', color: c.primaryDark }, + weatherTemp: { fontSize: 28, fontWeight: '700', color: c.primaryDark }, weatherDesc: { fontSize: 11, color: c.textSub, textAlign: 'center', marginTop: 2 }, - dateCard: { width: 90, backgroundColor: c.primaryDark, borderRadius: 14, padding: 14, alignItems: 'center', justifyContent: 'center' }, + dateCard: { width: 90, backgroundColor: c.primaryDark, borderRadius: 18, padding: 14, alignItems: 'center', justifyContent: 'center', shadowColor: c.isDark ? '#000000' : c.primary, shadowOpacity: c.isDark ? 0 : 0.15, shadowRadius: 16, shadowOffset: { width: 0, height: 4 }, elevation: c.isDark ? 0 : 6 }, dateToday: { fontSize: 10, color: 'rgba(255,255,255,0.6)', letterSpacing: 1.5, fontWeight: '700' }, - dateNum: { fontSize: 36, fontWeight: 'bold', color: '#fff', lineHeight: 40 }, + dateNum: { fontSize: 36, fontWeight: '700', color: '#fff', lineHeight: 40 }, dateMonth: { fontSize: 12, color: 'rgba(255,255,255,0.7)', marginTop: 2 }, - sectionTitle: { fontSize: 13, fontWeight: '700', color: c.textSub, letterSpacing: 0.5, marginHorizontal: 16, marginTop: 16, marginBottom: 8 }, - shiftCard: { backgroundColor: c.card, borderRadius: 14, marginHorizontal: 16, padding: 16, flexDirection: 'row', gap: 14, shadowColor: '#000', shadowOpacity: c.isDark ? 0 : 0.06, shadowRadius: 8, elevation: c.isDark ? 0 : 3, minHeight: 90, borderWidth: c.isDark ? 1 : 0, borderColor: c.border }, - shiftStrip: { width: 4, borderRadius: 2, backgroundColor: GOLD, marginRight: 2 }, + sectionTitle: { fontSize: 11, fontWeight: '800', color: c.textSub, letterSpacing: 1.5, textTransform: 'uppercase' as const, marginHorizontal: 16, marginTop: 20, marginBottom: 10 }, + shiftCard: { backgroundColor: c.card, borderRadius: 18, marginHorizontal: 16, padding: 16, flexDirection: 'row', gap: 14, shadowColor: c.isDark ? '#000000' : c.primary, shadowOpacity: c.isDark ? 0 : 0.08, shadowRadius: 10, shadowOffset: { width: 0, height: 2 }, elevation: c.isDark ? 0 : 3, minHeight: 140, borderWidth: 1, borderColor: c.glassBorder }, + shiftStrip: { width: 4, borderRadius: 2, backgroundColor: c.primary, marginRight: 2 }, shiftBadgeRow: { flexDirection: 'row', marginBottom: 8 }, inProgressBadge: { backgroundColor: '#D1FAE5', paddingHorizontal: 10, paddingVertical: 3, borderRadius: 20 }, inProgressText: { fontSize: 10, fontWeight: '700', color: '#059669' }, - shiftTitle: { fontSize: 17, fontWeight: 'bold', color: c.primaryDark, marginBottom: 4 }, - shiftTime: { fontSize: 22, fontWeight: 'bold', color: c.primary, marginBottom: 4 }, - timelineCard: { backgroundColor: c.card, borderRadius: 14, marginHorizontal: 16, marginTop: 12, padding: 16, shadowColor: '#000', shadowOpacity: c.isDark ? 0 : 0.06, shadowRadius: 8, elevation: c.isDark ? 0 : 3, borderWidth: c.isDark ? 1 : 0, borderColor: c.border }, + shiftTitle: { fontSize: 17, fontWeight: '700', color: c.primaryDark, marginBottom: 4 }, + shiftTime: { fontSize: 22, fontWeight: '700', color: c.primary, marginBottom: 4 }, + timelineCard: { backgroundColor: c.card, borderRadius: 18, marginHorizontal: 16, marginTop: 12, padding: 16, shadowColor: c.isDark ? '#000000' : c.primary, shadowOpacity: c.isDark ? 0 : 0.08, shadowRadius: 10, shadowOffset: { width: 0, height: 2 }, elevation: c.isDark ? 0 : 3, borderWidth: 1, borderColor: c.glassBorder }, restRow: { flexDirection: 'row', alignItems: 'center' }, - restText: { fontSize: 18, fontWeight: 'bold', color: '#10b981' }, - emptyShift: { color: c.textSub, fontSize: 15, lineHeight: 24, textAlign: 'center', flex: 1 }, - uploadToggle: { flexDirection: 'row', alignItems: 'center', gap: 10, marginHorizontal: 16, marginTop: 16, backgroundColor: c.card, borderRadius: 14, paddingHorizontal: 16, paddingVertical: 14, shadowColor: '#000', shadowOpacity: c.isDark ? 0 : 0.05, shadowRadius: 6, elevation: c.isDark ? 0 : 2, borderWidth: c.isDark ? 1 : 0, borderColor: c.border }, - uploadToggleText: { flex: 1, fontSize: 15, fontWeight: '600', color: c.primaryDark }, - uploadSection: { marginHorizontal: 16, backgroundColor: c.card, borderRadius: 14, padding: 16, marginTop: 2, shadowColor: '#000', shadowOpacity: c.isDark ? 0 : 0.04, shadowRadius: 4, elevation: c.isDark ? 0 : 1, borderWidth: c.isDark ? 1 : 0, borderColor: c.border }, - uploadDesc: { fontSize: 13, color: c.textSub, lineHeight: 19, marginBottom: 14 }, - scanBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', backgroundColor: c.primaryDark, borderRadius: 12, paddingVertical: 13, paddingHorizontal: 20 }, - scanBtnText: { color: '#fff', fontWeight: 'bold', fontSize: 15 }, - imagesRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 12 }, - thumb: { width: '47%', height: 120, borderRadius: 10, resizeMode: 'cover' }, - ocrResult: { backgroundColor: c.cardSecondary, borderRadius: 10, padding: 12, marginTop: 12 }, - ocrTitle: { fontSize: 12, fontWeight: '700', color: c.textSub, marginBottom: 6 }, - ocrText: { fontSize: 12, color: c.text, lineHeight: 18 }, - syncBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', backgroundColor: c.primary, borderRadius: 12, paddingVertical: 13, marginTop: 12 }, - syncBtnText: { color: '#fff', fontWeight: 'bold', fontSize: 15 }, - modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'center', alignItems: 'center', padding: 20 }, - modalContent: { backgroundColor: c.isDark ? c.bg : c.card, width: '100%', borderRadius: 16, padding: 20, shadowColor: '#000', shadowOpacity: c.isDark ? 0 : 0.1, shadowRadius: 10, elevation: c.isDark ? 0 : 5, borderWidth: c.isDark ? 1 : 0, borderColor: c.border }, - modalTitle: { fontSize: 17, fontWeight: 'bold', color: c.primaryDark, marginBottom: 14 }, - modalLabel: { fontSize: 12, fontWeight: '700', color: c.textSub, marginBottom: 8 }, - modalInput: { borderWidth: 1, borderColor: c.border, borderRadius: 10, padding: 12, marginBottom: 10, fontSize: 14, color: c.text }, - modalBtn: { flex: 1, padding: 14, borderRadius: 10, alignItems: 'center' }, - typeBtn: { flex: 1, padding: 12, borderRadius: 10, backgroundColor: c.bg, alignItems: 'center' }, - inputLabel: { fontSize: 11, color: c.textSub, fontWeight: '700', marginBottom: 4, letterSpacing: 0.5 }, - modeBtn: { flex: 1, backgroundColor: c.primary, borderRadius: 12, paddingVertical: 20, alignItems: 'center', justifyContent: 'center', gap: 8, shadowColor: '#000', shadowOpacity: 0.05, shadowRadius: 4, elevation: 1 }, - modeBtnText: { color: '#fff', fontWeight: '700', fontSize: 13 }, + restText: { fontSize: 18, fontWeight: '700', color: '#10b981' }, + emptyShiftWrap: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingVertical: 24 }, + emptyIconCircle: { width: 64, height: 64, borderRadius: 32, backgroundColor: c.primaryLight, justifyContent: 'center', alignItems: 'center', marginBottom: 12 }, + emptyShiftTitle: { fontSize: 16, fontWeight: '700', color: c.text, textAlign: 'center', marginBottom: 4 }, + emptyShiftSub: { fontSize: 13, color: c.textSub, textAlign: 'center', lineHeight: 18, paddingHorizontal: 16 }, }); } diff --git a/src/screens/ManualsScreen.tsx b/src/screens/ManualsScreen.tsx index f20e5b3..ad9d783 100644 --- a/src/screens/ManualsScreen.tsx +++ b/src/screens/ManualsScreen.tsx @@ -5,7 +5,7 @@ import { LayoutAnimation, Platform, UIManager, TextInput, Modal, Alert, KeyboardAvoidingView, } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; -import { useAppTheme } from '../context/ThemeContext'; +import { useAppTheme, type ThemeColors } from '../context/ThemeContext'; const STORAGE_KEY = 'manuals_data_v2'; @@ -416,7 +416,7 @@ function CommandsTab({ commands, colors }: { commands: DCSCommand[]; colors: any } // ─── Item component ─────────────────────────────────────────────────────────── -function makeItemStyles(c: any) { +function makeItemStyles(c: ThemeColors) { return StyleSheet.create({ wrapper: { backgroundColor: c.card, @@ -485,7 +485,7 @@ function ManualItemRow({ } // ─── Section component ──────────────────────────────────────────────────────── -function makeSectionStyles(c: any) { +function makeSectionStyles(c: ThemeColors) { return StyleSheet.create({ wrapper: { marginBottom: 12, @@ -583,7 +583,7 @@ const modalStyles = StyleSheet.create({ }); // ─── Main Screen ────────────────────────────────────────────────────────────── -function makeStyles(c: any) { +function makeStyles(c: ThemeColors) { return StyleSheet.create({ root: { flex: 1, backgroundColor: c.bg }, header: { diff --git a/src/screens/NotepadScreen.tsx b/src/screens/NotepadScreen.tsx index 4a71441..9114a9e 100644 --- a/src/screens/NotepadScreen.tsx +++ b/src/screens/NotepadScreen.tsx @@ -5,11 +5,12 @@ import { } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { MaterialIcons } from '@expo/vector-icons'; -import { useAppTheme } from '../context/ThemeContext'; +import { useAppTheme, type ThemeColors } from '../context/ThemeContext'; +import { useLanguage } from '../context/LanguageContext'; const STORAGE_KEY = 'aerostaff_notepad_v1'; -function makeStyles(c: any) { +function makeStyles(c: ThemeColors) { return StyleSheet.create({ root: { flex: 1, backgroundColor: c.bg }, toolbar: { @@ -27,7 +28,9 @@ function makeStyles(c: any) { backgroundColor: c.primary, borderRadius: 10, paddingHorizontal: 14, paddingVertical: 8, }, - saveBtnDim: { backgroundColor: '#93C5FD' }, + // Dims the entire save button (background + icon + label) when content is + // already saved — intentional: the full-button fade signals an inactive state. + saveBtnDim: { opacity: 0.55 }, saveTxt: { color: '#fff', fontWeight: '600', fontSize: 13 }, statusBar: { flexDirection: 'row', alignItems: 'center', gap: 6, @@ -48,6 +51,7 @@ function makeStyles(c: any) { export default function NotepadScreen() { const { colors } = useAppTheme(); + const { t } = useLanguage(); const s = useMemo(() => makeStyles(colors), [colors]); const [text, setText] = useState(''); const [saved, setSaved] = useState(true); @@ -75,12 +79,12 @@ export default function NotepadScreen() { const clear = useCallback(() => { Alert.alert( - 'Cancella note', - 'Sei sicuro di voler cancellare tutte le note?', + t('notepadClearTitle'), + t('notepadClearMsg'), [ { text: 'Annulla', style: 'cancel' }, { - text: 'Cancella', + text: t('notepadClearConfirm'), style: 'destructive', onPress: () => { setText(''); @@ -102,7 +106,7 @@ export default function NotepadScreen() { - Blocco Note + {t('notepadTitle')} @@ -113,7 +117,7 @@ export default function NotepadScreen() { style={[s.saveBtn, saved && s.saveBtnDim]} > - {saved ? 'Salvato' : 'Salva'} + {saved ? t('notepadSaved') : t('notepadSave')} @@ -122,9 +126,9 @@ export default function NotepadScreen() { - {saved ? 'Salvato' : 'Modifiche non salvate'} + {saved ? 'Salvato' : t('notepadUnsaved')} - {charCount} caratteri + {charCount} {t('notepadChars')} {/* Text input */} diff --git a/src/screens/PasswordScreen.tsx b/src/screens/PasswordScreen.tsx index 54ed239..cd7ae56 100644 --- a/src/screens/PasswordScreen.tsx +++ b/src/screens/PasswordScreen.tsx @@ -6,13 +6,15 @@ import { import AsyncStorage from '@react-native-async-storage/async-storage'; import * as SecureStore from 'expo-secure-store'; import { MaterialIcons } from '@expo/vector-icons'; -import { useAppTheme } from '../context/ThemeContext'; +import { useAppTheme, type ThemeColors } from '../context/ThemeContext'; +import { useLanguage } from '../context/LanguageContext'; -const PASSWORDS_KEY = 'aerostaff_passwords_v1'; +const PASSWORDS_KEY = 'aerostaff_passwords_v2'; // v2: migrated to SecureStore +const PASSWORDS_KEY_LEGACY = 'aerostaff_passwords_v1'; // v1: was plain AsyncStorage const PIN_KEY = 'aerostaff_pin_v1'; const PIN_ENABLED_KEY = 'aerostaff_pin_enabled_v1'; -// Secure helpers — PIN is stored in the OS keychain, not plain AsyncStorage. +// Secure helpers — passwords and PIN are stored in the OS keychain. async function getSecurePin(): Promise { try { return await SecureStore.getItemAsync(PIN_KEY); } catch { return AsyncStorage.getItem(PIN_KEY); } // fallback for older installs @@ -25,6 +27,56 @@ async function deleteSecurePin(): Promise { await AsyncStorage.removeItem(PIN_KEY).catch(() => {}); // clean up legacy } +// SecureStore has a 2KB limit per key — chunk if needed +const CHUNK_SIZE = 1800; // safe limit per SecureStore value + +async function getSecurePasswords(): Promise { + try { + // Try single key first + const single = await SecureStore.getItemAsync(PASSWORDS_KEY); + if (single) return single; + // Try chunked + const chunk0 = await SecureStore.getItemAsync(`${PASSWORDS_KEY}_0`); + if (!chunk0) { + // Migrate from legacy AsyncStorage (v1) + const legacy = await AsyncStorage.getItem(PASSWORDS_KEY_LEGACY); + if (legacy) { + await setSecurePasswords(legacy); + await AsyncStorage.removeItem(PASSWORDS_KEY_LEGACY); + return legacy; + } + return null; + } + let full = ''; + for (let i = 0; ; i++) { + const chunk = await SecureStore.getItemAsync(`${PASSWORDS_KEY}_${i}`); + if (!chunk) break; + full += chunk; + } + return full; + } catch { + // Last resort fallback + return AsyncStorage.getItem(PASSWORDS_KEY_LEGACY); + } +} + +async function setSecurePasswords(json: string): Promise { + if (json.length <= CHUNK_SIZE) { + await SecureStore.setItemAsync(PASSWORDS_KEY, json); + // Clean up old chunks if any + for (let i = 0; ; i++) { + try { await SecureStore.deleteItemAsync(`${PASSWORDS_KEY}_${i}`); } catch { break; } + } + } else { + // Chunk mode + await SecureStore.deleteItemAsync(PASSWORDS_KEY).catch(() => {}); + const chunks = Math.ceil(json.length / CHUNK_SIZE); + for (let i = 0; i < chunks; i++) { + await SecureStore.setItemAsync(`${PASSWORDS_KEY}_${i}`, json.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE)); + } + } +} + type PasswordEntry = { id: string; name: string; @@ -50,6 +102,7 @@ const EMPTY_MODAL: ModalState = { // ─── PIN Overlay ───────────────────────────────────────────────────────────── function PinOverlay({ onUnlock, onCancel, title }: { onUnlock: (pin: string) => void; onCancel?: () => void; title: string }) { const { colors } = useAppTheme(); + const { t } = useLanguage(); const s = useMemo(() => makePinStyles(colors), [colors]); const [digits, setDigits] = useState(''); @@ -132,6 +185,7 @@ function PasswordRow({ item, onEdit, onDelete }: { item: PasswordEntry; onEdit: // ─── Main Screen ────────────────────────────────────────────────────────────── export default function PasswordScreen() { const { colors } = useAppTheme(); + const { t } = useLanguage(); const s = useMemo(() => makeStyles(colors), [colors]); const [entries, setEntries] = useState([]); @@ -140,10 +194,10 @@ export default function PasswordScreen() { const [pinEnabled, setPinEnabled] = useState(false); const [pinMode, setPinMode] = useState<'unlock' | 'setup' | null>(null); - // Load on mount + // Load on mount (from SecureStore, with legacy AsyncStorage migration) useEffect(() => { (async () => { - const raw = await AsyncStorage.getItem(PASSWORDS_KEY); + const raw = await getSecurePasswords(); if (raw) setEntries(JSON.parse(raw)); const enabled = await AsyncStorage.getItem(PIN_ENABLED_KEY); const isEnabled = enabled === 'true'; @@ -154,20 +208,20 @@ export default function PasswordScreen() { const persist = useCallback(async (next: PasswordEntry[]) => { setEntries(next); - await AsyncStorage.setItem(PASSWORDS_KEY, JSON.stringify(next)); + await setSecurePasswords(JSON.stringify(next)); }, []); // PIN toggle const togglePin = useCallback(async () => { if (pinEnabled) { - Alert.alert('Disattiva PIN', 'Vuoi rimuovere la protezione PIN?', [ + Alert.alert(t('pinDisableTitle'), t('pinDisableMsg'), [ { text: 'Annulla', style: 'cancel' }, - { text: 'Disattiva', style: 'destructive', onPress: async () => { + { text: t('pinDisableBtn'), style: 'destructive', onPress: async () => { try { setPinEnabled(false); await AsyncStorage.setItem(PIN_ENABLED_KEY, 'false'); await deleteSecurePin(); - } catch (e) { console.error('[pin] disable error', e); } + } catch (e) { if (__DEV__) console.error('[pin] disable error', e); } }}, ]); } else { @@ -181,10 +235,10 @@ export default function PasswordScreen() { await AsyncStorage.setItem(PIN_ENABLED_KEY, 'true'); setPinEnabled(true); setPinMode(null); - Alert.alert('PIN impostato', 'La schermata password è ora protetta.'); + Alert.alert(t('pinSetTitle'), t('pinSetMsg')); } catch (e) { - console.error('[pin] setup error', e); - Alert.alert('Errore', 'Impossibile impostare il PIN. Riprova.'); + if (__DEV__) console.error('[pin] setup error', e); + Alert.alert('Errore', t('pinErrMsg')); } }, []); @@ -194,11 +248,11 @@ export default function PasswordScreen() { if (pin === stored) { setPinMode(null); } else { - Alert.alert('PIN errato', 'Riprova.'); + Alert.alert(t('pinWrong'), t('pinWrongMsg')); } } catch (e) { - console.error('[pin] unlock error', e); - Alert.alert('Errore', 'Impossibile verificare il PIN. Riprova.'); + if (__DEV__) console.error('[pin] unlock error', e); + Alert.alert('Errore', t('pinVerifyErr')); } }, []); @@ -210,8 +264,8 @@ export default function PasswordScreen() { }, []); const saveModal = useCallback(async () => { - if (!modal.name.trim()) { Alert.alert('Errore', 'Il nome è obbligatorio.'); return; } - if (!modal.password.trim()) { Alert.alert('Errore', 'La password è obbligatoria.'); return; } + if (!modal.name.trim()) { Alert.alert('Errore', t('passwordErrName')); return; } + if (!modal.password.trim()) { Alert.alert('Errore', t('passwordErrPw')); return; } let next: PasswordEntry[]; if (modal.editingId) { next = entries.map(e => e.id === modal.editingId @@ -233,7 +287,7 @@ export default function PasswordScreen() { }, [modal, entries, persist]); const deleteEntry = useCallback((id: string) => { - Alert.alert('Elimina', 'Vuoi eliminare questa voce?', [ + Alert.alert(t('passwordDeleteTitle'), t('passwordDeleteMsg'), [ { text: 'Annulla', style: 'cancel' }, { text: 'Elimina', style: 'destructive', onPress: async () => { await persist(entries.filter(e => e.id !== id)); @@ -255,7 +309,7 @@ export default function PasswordScreen() { - Password + {t('passwordTitle')} - Aggiungi + {t('passwordAdd')} @@ -289,8 +343,8 @@ export default function PasswordScreen() { ListEmptyComponent={ - Nessuna password salvata. - Premi "Aggiungi" per iniziare. + {t('passwordEmptyTxt')} + {t('passwordEmptySubTxt')} } showsVerticalScrollIndicator={false} @@ -305,15 +359,15 @@ export default function PasswordScreen() { > - {modal.editingId ? 'Modifica voce' : 'Nuova voce'} + {modal.editingId ? t('passwordModalEdit') : t('passwordModalNew')} - Nome * - setModal(m => ({ ...m, name: v }))} placeholder="es. Portale HR EasyJet" placeholderTextColor={colors.textMuted} /> + {t('passwordNameLabel')} + setModal(m => ({ ...m, name: v }))} placeholder={t('passwordNamePh')} placeholderTextColor={colors.textMuted} /> - Username / Email - setModal(m => ({ ...m, username: v }))} placeholder="es. mario.rossi@easyjet.com" placeholderTextColor={colors.textMuted} autoCapitalize="none" keyboardType="email-address" /> + {t('passwordUsernameLabel')} + setModal(m => ({ ...m, username: v }))} placeholder={t('passwordUsernamePh')} placeholderTextColor={colors.textMuted} autoCapitalize="none" keyboardType="email-address" /> - Password * + {t('passwordPwLabel')} - Note + {t('passwordNotesLabel')} setModal(m => ({ ...m, notes: v }))} placeholder="es. scade ogni 90 giorni…" placeholderTextColor={colors.textMuted} multiline numberOfLines={3} textAlignVertical="top" /> @@ -349,7 +403,7 @@ export default function PasswordScreen() { } // ─── Styles ─────────────────────────────────────────────────────────────────── -function makePinStyles(c: any) { +function makePinStyles(c: ThemeColors) { return StyleSheet.create({ overlay: { flex: 1, backgroundColor: c.bg, justifyContent: 'center', alignItems: 'center' }, box: { alignItems: 'center', padding: 32, width: '100%', maxWidth: 320 }, @@ -364,9 +418,9 @@ function makePinStyles(c: any) { }); } -function makeRowStyles(c: any) { +function makeRowStyles(c: ThemeColors) { return StyleSheet.create({ - card: { backgroundColor: c.card, borderRadius: 14, padding: 14, marginBottom: 10, flexDirection: 'row', alignItems: 'flex-start', borderWidth: 1, borderColor: c.border, shadowColor: '#000', shadowOpacity: c.isDark ? 0 : 0.05, shadowRadius: 6, elevation: c.isDark ? 0 : 2 }, + card: { backgroundColor: c.card, borderRadius: 16, padding: 14, marginBottom: 10, flexDirection: 'row', alignItems: 'flex-start', borderWidth: 1, borderColor: c.glassBorder, shadowColor: c.primary, shadowOpacity: c.isDark ? 0 : 0.08, shadowRadius: 8, elevation: c.isDark ? 0 : 3 }, cardLeft:{ flex: 1 }, name: { fontSize: 15, fontWeight: '700', color: c.primaryDark, marginBottom: 2 }, username:{ fontSize: 12, color: c.textSub, marginBottom: 4 }, @@ -380,7 +434,7 @@ function makeRowStyles(c: any) { }); } -function makeStyles(c: any) { +function makeStyles(c: ThemeColors) { return StyleSheet.create({ root: { flex: 1, backgroundColor: c.bg }, toolbar: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 16, paddingVertical: 12, backgroundColor: c.card, borderBottomWidth: 1, borderBottomColor: c.border }, diff --git a/src/screens/PhonebookScreen.tsx b/src/screens/PhonebookScreen.tsx index 5f2cf27..b176f21 100644 --- a/src/screens/PhonebookScreen.tsx +++ b/src/screens/PhonebookScreen.tsx @@ -5,7 +5,8 @@ import { } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { MaterialIcons } from '@expo/vector-icons'; -import { useAppTheme } from '../context/ThemeContext'; +import { useAppTheme, type ThemeColors } from '../context/ThemeContext'; +import { useLanguage } from '../context/LanguageContext'; const STORAGE_KEY = 'aerostaff_phonebook_v1'; @@ -20,7 +21,7 @@ type Contact = { const CATEGORIES = ['Ops', 'Handling', 'Compagnia', 'Aeroporto', 'Hotel', 'Altro']; const CATEGORY_COLORS: Record = { - 'Ops': '#2563EB', + 'Ops': '#F47B16', 'Handling': '#16A34A', 'Compagnia': '#FF6600', 'Aeroporto': '#7C3AED', @@ -40,7 +41,7 @@ interface EditModalProps { onClose: () => void; } -function makeModalStyles(c: any) { +function makeModalStyles(c: ThemeColors) { return StyleSheet.create({ overlay: { flex: 1, justifyContent: 'flex-end', @@ -88,6 +89,7 @@ function makeModalStyles(c: any) { function EditModal({ visible, contact, onSave, onClose }: EditModalProps) { const { colors } = useAppTheme(); + const { t } = useLanguage(); const modalStyles = useMemo(() => makeModalStyles(colors), [colors]); const [name, setName] = useState(''); const [number, setNumber] = useState(''); @@ -107,7 +109,7 @@ function EditModal({ visible, contact, onSave, onClose }: EditModalProps) { const handleSave = () => { if (!name.trim() || !number.trim()) { - Alert.alert('Campi obbligatori', 'Nome e numero sono obbligatori.'); + Alert.alert(t('contactErrReqTitle'), t('contactErrReqMsg')); return; } onSave({ @@ -131,33 +133,33 @@ function EditModal({ visible, contact, onSave, onClose }: EditModalProps) { - {contact?.id ? 'Modifica contatto' : 'Nuovo contatto'} + {contact?.id ? t('contactModalEdit') : t('contactModalNew')} {/* Nome */} - Nome * + {t('contactNameLabel')} {/* Numero */} - Numero * + {t('contactNumberLabel')} {/* Categoria */} - Categoria + {t('contactCatLabel')} {CATEGORIES.map(cat => ( {/* Nota */} - Nota (opzionale) + {t('contactNoteLabel')} @@ -205,7 +207,7 @@ function EditModal({ visible, contact, onSave, onClose }: EditModalProps) { // ─── Riga contatto ──────────────────────────────────────────────────────────── -function makeRowStyles(c: any) { +function makeRowStyles(c: ThemeColors) { return StyleSheet.create({ card: { flexDirection: 'row', alignItems: 'center', @@ -240,6 +242,7 @@ interface ContactRowProps { function ContactRow({ contact, onEdit, onDelete }: ContactRowProps) { const { colors } = useAppTheme(); + const { t } = useLanguage(); const rowStyles = useMemo(() => makeRowStyles(colors), [colors]); const color = CATEGORY_COLORS[contact.category] ?? '#64748B'; @@ -251,9 +254,9 @@ function ContactRow({ contact, onEdit, onDelete }: ContactRowProps) { }; const confirmDelete = () => { - Alert.alert('Elimina contatto', `Eliminare "${contact.name}"?`, [ + Alert.alert(t('contactDeleteTitle'), `${t('contactDeleteTitle')} "${contact.name}"?`, [ { text: 'Annulla', style: 'cancel' }, - { text: 'Elimina', style: 'destructive', onPress: () => onDelete(contact.id) }, + { text: t('contactDeleteConfirm'), style: 'destructive', onPress: () => onDelete(contact.id) }, ]); }; @@ -285,7 +288,7 @@ function ContactRow({ contact, onEdit, onDelete }: ContactRowProps) { // ─── Main Screen ────────────────────────────────────────────────────────────── -function makeStyles(c: any) { +function makeStyles(c: ThemeColors) { return StyleSheet.create({ root: { flex: 1, backgroundColor: c.bg }, header: { @@ -339,6 +342,7 @@ function makeStyles(c: any) { export default function PhonebookScreen() { const { colors } = useAppTheme(); + const { t } = useLanguage(); const s = useMemo(() => makeStyles(colors), [colors]); const [contacts, setContacts] = useState([]); const [search, setSearch] = useState(''); @@ -395,10 +399,10 @@ export default function PhonebookScreen() { {/* Header */} - Rubrica + {t('phonebookTitle')} - Aggiungi + {t('contactAdd')} @@ -409,7 +413,7 @@ export default function PhonebookScreen() { style={s.searchInput} value={search} onChangeText={setSearch} - placeholder="Cerca nome o numero..." + placeholder={t('contactSearch')} placeholderTextColor={colors.textSub} autoCorrect={false} /> @@ -430,7 +434,7 @@ export default function PhonebookScreen() { style={[s.filterChip, !filterCat && s.filterChipActive]} onPress={() => setFilterCat(null)} > - Tutti + {t('contactAll')} {CATEGORIES.map(cat => { const active = filterCat === cat; @@ -454,13 +458,13 @@ export default function PhonebookScreen() { {contacts.length === 0 ? ( - Rubrica vuota + {t('contactEmptyTitle')} Tocca "Aggiungi" per inserire il primo contatto ) : filtered.length === 0 ? ( - Nessun risultato + {t('contactNoResults')} ) : ( Object.entries(grouped).map(([cat, list]) => ( diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 80414ea..b6e3a14 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -1,3 +1,4 @@ +import { version } from '../../package.json'; import React, { useState } from 'react'; import { View, Text, StyleSheet, ScrollView, TouchableOpacity, Switch, ActivityIndicator, @@ -7,6 +8,7 @@ import { LinearGradient } from 'expo-linear-gradient'; import { MaterialIcons } from '@expo/vector-icons'; import { useAppTheme, ThemeMode } from '../context/ThemeContext'; import { useAirport } from '../context/AirportContext'; +import { useLanguage } from '../context/LanguageContext'; import { AIRPORT_PRESETS, formatAirportSettingLabel, @@ -32,7 +34,7 @@ const THEME_OPTIONS: ThemeOption[] = [ sublabel: 'Tema standard, sfondo bianco', icon: 'light-mode', previewBg: '#F3F4F6', - previewAccent: '#2563EB', + previewAccent: '#F47B16', }, { id: 'dark', @@ -40,7 +42,7 @@ const THEME_OPTIONS: ThemeOption[] = [ sublabel: 'Ideale di notte, riduce affaticamento', icon: 'dark-mode', previewBg: '#0F172A', - previewAccent: '#3B82F6', + previewAccent: '#FF9A42', }, { id: 'weather', @@ -53,10 +55,11 @@ const THEME_OPTIONS: ThemeOption[] = [ }, ]; -function ThemeCard({ option, selected, onSelect }: { +function ThemeCard({ option, selected, onSelect, activeLabel }: { option: ThemeOption; selected: boolean; onSelect: () => void; + activeLabel: string; }) { const { colors } = useAppTheme(); return ( @@ -103,7 +106,7 @@ function ThemeCard({ option, selected, onSelect }: { {selected && ( - Attivo + {activeLabel} )} @@ -161,7 +164,14 @@ function SettingRow({ export default function SettingsScreen() { const { colors, mode, setMode, isLoading } = useAppTheme(); const { airport, airportCode, setAirportCode, isLoading: airportLoading } = useAirport(); + const { t, lang, setLang, languages } = useLanguage(); const [airportModalOpen, setAirportModalOpen] = useState(false); + + const translatedOptions = THEME_OPTIONS.map(opt => ({ + ...opt, + label: opt.id === 'light' ? t('themeLight') : opt.id === 'dark' ? t('themeDark') : t('themeWeather'), + sublabel: opt.id === 'light' ? t('themeLightSub') : opt.id === 'dark' ? t('themeDarkSub') : t('themeWeatherSub'), + })); const [airportInput, setAirportInput] = useState(airportCode); const openAirportModal = () => { @@ -177,19 +187,16 @@ export default function SettingsScreen() { const saveAirport = async () => { const normalized = normalizeAirportCode(airportInput); if (!isValidAirportCode(normalized)) { - Alert.alert('Codice non valido', 'Inserisci un codice IATA di 3 lettere, per esempio PSA o FCO.'); + Alert.alert(t('airportAlertInvalidTitle'), t('airportAlertInvalidMsg')); return; } try { await setAirportCode(normalized); setAirportModalOpen(false); - Alert.alert( - 'Aeroporto aggiornato', - 'Voli, timeline, widget e notifiche useranno il nuovo aeroporto.', - ); + Alert.alert(t('airportAlertUpdatedTitle'), t('airportAlertUpdatedMsg')); } catch { - Alert.alert('Errore', 'Non sono riuscito a salvare il nuovo aeroporto.'); + Alert.alert(t('airportAlertErrorTitle'), t('airportAlertErrorMsg')); } }; @@ -206,69 +213,96 @@ export default function SettingsScreen() { - Impostazioni - AeroStaff Pro · v1.1.0 + {t('settingsTitle')} + {`AeroStaff Pro · v${version}`} {/* ── Sezione Tema ── */} - TEMA + {t('sectionTheme')} {isLoading ? ( - Caricamento tema meteo… + {t('themeLoading')} ) : ( - {THEME_OPTIONS.map(opt => ( + {translatedOptions.map(opt => ( setMode(opt.id)} + activeLabel={t('themeActive')} /> ))} )} {/* ── Sezione Account ── */} - ACCOUNT + {t('sectionAccount')} - + - + {/* ── Sezione Aeroporto ── */} - AEROPORTO + {t('sectionAirport')} - + {/* ── Sezione Notifiche ── */} - NOTIFICHE + {t('sectionNotifications')} - + - + {/* ── Info app ── */} - APP + {t('sectionApp')} + + + + + + {/* ── Sezione Lingua ── */} + {t('sectionLanguage')} - + {languages.map((langOpt, idx) => ( + + {idx > 0 && } + setLang(langOpt.code)} + activeOpacity={0.8} + > + + {langOpt.flag} + + + {langOpt.label} + + {lang === langOpt.code && ( + + )} + + + ))} @@ -286,12 +320,13 @@ export default function SettingsScreen() { > - Cambia aeroporto + {t('airportModalTitle')} - Inserisci un codice IATA di 3 lettere. Il cambio aggiorna voli, timeline, widget e notifiche. + + {t('airportModalCopy')} - Codice aeroporto + {t('airportModalLabel')} setAirportInput(normalizeAirportCode(text))} @@ -303,7 +338,7 @@ export default function SettingsScreen() { style={[styles.modalInput, { color: colors.text, borderColor: colors.border, backgroundColor: colors.bg }]} /> - Scelta rapida + {t('airportModalQuickPick')} {AIRPORT_PRESETS.map(item => { const selected = airportInput === item.code; @@ -337,14 +372,14 @@ export default function SettingsScreen() { onPress={closeAirportModal} activeOpacity={0.85} > - Annulla + {t('cancel')} - Salva + {t('save')} @@ -394,8 +429,8 @@ const styles = StyleSheet.create({ // Generic rows card: { borderRadius: 16, marginBottom: 20, - shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.04, shadowRadius: 4, elevation: 2, overflow: 'hidden', + shadowColor: '#F47B16', shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.06, shadowRadius: 6, elevation: 2, overflow: 'hidden', }, divider: { height: 1, marginLeft: 56 }, row: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 13, gap: 12 }, diff --git a/src/screens/ShiftScreen.tsx b/src/screens/ShiftScreen.tsx deleted file mode 100644 index 102709b..0000000 --- a/src/screens/ShiftScreen.tsx +++ /dev/null @@ -1,336 +0,0 @@ -import React, { useState, useRef } from 'react'; -import { View, Text, StyleSheet, ActivityIndicator, ScrollView, Alert, TouchableOpacity, Image } from 'react-native'; -import * as ImagePicker from 'expo-image-picker'; -import { WebView } from 'react-native-webview'; -import * as Calendar from 'expo-calendar'; - -const PRIMARY = '#2563EB'; -const DARK_BLUE = '#1E3A8A'; -const BG = '#F3F4F6'; - -export default function ShiftScreen() { - const [imageList, setImageList] = useState([]); - const [ocrText, setOcrText] = useState(''); - const [processing, setProcessing] = useState(false); - const webViewRef = useRef(null); - - const pickImage = async () => { - try { - let result = await ImagePicker.launchImageLibraryAsync({ - mediaTypes: ImagePicker.MediaTypeOptions.Images, - allowsMultipleSelection: true, - quality: 1, - base64: true, - }); - - if (!result.canceled && result.assets && result.assets.length > 0) { - setImageList(result.assets.map(a => a.uri)); - setProcessing(true); - setOcrText(''); - - const base64List = result.assets.map(a => `data:image/jpeg;base64,${a.base64}`); - const base64Json = JSON.stringify(base64List).replace(/'/g, "\\'"); - - const jsCode = ` - if (window.runTesseract) { - window.runTesseract('${base64Json}'); - } else { - window.ReactNativeWebView.postMessage(JSON.stringify({ success: false, error: "Motore OCR non pronto." })); - } - true; - `; - webViewRef.current?.injectJavaScript(jsCode); - } - } catch (e) { - Alert.alert("Errore OCR", "Impossibile elaborare l'immagine."); - setProcessing(false); - } - }; - - const handleWebViewMessage = (event: any) => { - const rawData = event.nativeEvent.data; - try { - const result = JSON.parse(rawData); - if (result.success) { - setOcrText(result.text); - } else { - Alert.alert("Errore", "Impossibile analizzare il documento: " + result.error); - } - } catch(e) { - console.error(e); - } finally { - setProcessing(false); - } - }; - - const parseAndSaveShifts = async () => { - const { status } = await Calendar.requestCalendarPermissionsAsync(); - if (status !== 'granted') { - Alert.alert("Permesso negato", "Devi autorizzare l'accesso al calendario del telefono."); - return; - } - - try { - const calendars = await Calendar.getCalendarsAsync(Calendar.EntityTypes.EVENT); - // Su iOS isPrimary è comodo, su Android cerchiamo un calendario che accetti scritture - let targetCalendar = calendars.find(c => c.allowsModifications && c.isPrimary); - if (!targetCalendar) { - targetCalendar = calendars.find(c => c.allowsModifications); - } - - if (!targetCalendar) { - Alert.alert('Errore', 'Nessun calendario scrivibile trovato sul dispositivo.'); - return; - } - // Normalizzazione Estrema OCR globale prima di estrarre - const norText = ocrText.replace(/[OoQ]/g, '0').replace(/[Il|]/g, '1'); - - // Estrai tutte le date in ordine compatto - const dateRegex = /\b(\d{2})[\/\-](\d{2})[\/\-](\d{4})\b/g; - const dates: any[] = []; - let matchDate; - while ((matchDate = dateRegex.exec(norText)) !== null) { - dates.push({ - day: parseInt(matchDate[1], 10), - month: parseInt(matchDate[2], 10) - 1, // JS months are 0-indexed - year: parseInt(matchDate[3], 10), - raw: matchDate[0] - }); - } - - // Nascondiamo gli anni a 4 cifre per evitare che "2026" possa essere letto come l'orario "20:26" dall'OCR - const safeTextForTimes = norText.replace(/\b20\d{2}\b/g, ' ANNO '); - - // Estrai tutti i turni (orari o Riposo) in ordine compatto - // Tolto il flag 'i' per evitare falsi positivi sulla lettera 'r' minuscola (es. o[r]ario, ma[r]tedì, ecc.) - const shiftRegex = /\b([01]?\d|2\d)[.,:]?(\d{2})\s*[-–—_~|]+\s*([01]?\d|2\d)[.,:]?(\d{2})\b|\b(R|RIP|RIP0S0|R1P0S0|R1POSO)\b/g; - const shifts: any[] = []; - let matchShift; - while ((matchShift = shiftRegex.exec(safeTextForTimes)) !== null) { - if (matchShift[5]) { - shifts.push({ isRest: true, raw: matchShift[0] }); - } else { - shifts.push({ - isRest: false, - startH: parseInt(matchShift[1], 10), startM: parseInt(matchShift[2], 10), - endH: parseInt(matchShift[3], 10), endM: parseInt(matchShift[4], 10), - raw: matchShift[0] - }); - } - } - - let savedCount = 0; - // ZIP degli array: Associa la prima data al primo turno, la seconda al secondo, ecc. - // E' perfetto per le estrazioni in colonna! - const iterCount = Math.min(dates.length, shifts.length); - - for (let i = 0; i < iterCount; i++) { - const d = dates[i]; - const s = shifts[i]; - - // --- PREVENZIONE DUPLICATI --- - // Controlliamo l'intera giornata per evitare sovrascritture se l'operazione viene ripetuta - const dayStart = new Date(d.year, d.month, d.day, 0, 0, 0); - const dayEnd = new Date(d.year, d.month, d.day, 23, 59, 59); - const existingEvents = await Calendar.getEventsAsync([targetCalendar.id], dayStart, dayEnd); - - const isDuplicate = existingEvents.some(e => { - if (s.isRest) { - return e.title.includes("Riposo"); - } else { - // Verifica se c'è già un turno di lavoro che inizia alla stessa ora - const eStart = new Date(e.startDate); - return e.title.includes("Lavoro") && eStart.getHours() === s.startH; - } - }); - - if (isDuplicate) { - continue; // Salta alla prossima iterazione senza aggiungere - } - // ------------------------------ - - if (s.isRest) { - const alldayStart = new Date(d.year, d.month, d.day, 12, 0, 0); - const alldayEnd = new Date(d.year, d.month, d.day, 14, 0, 0); - await Calendar.createEventAsync(targetCalendar.id, { - title: "🌴 Riposo", - startDate: alldayStart, - endDate: alldayEnd, - allDay: true, - notes: "Dati estratti: " + d.raw + " -> " + s.raw, - timeZone: 'Europe/Rome', - }); - savedCount++; - } else { - const startDate = new Date(d.year, d.month, d.day); - startDate.setHours(s.startH, s.startM, 0, 0); - - let endDate = new Date(d.year, d.month, d.day); - endDate.setHours(s.endH, s.endM, 0, 0); - - if (endDate <= startDate) { - endDate.setDate(endDate.getDate() + 1); // Notturno - } - - await Calendar.createEventAsync(targetCalendar.id, { - title: "Turno Lavoro ✈️", - startDate: startDate, - endDate: endDate, - notes: "Dati estratti: " + d.raw + " -> " + s.raw, - timeZone: 'Europe/Rome', - }); - savedCount++; - } - } - - if (savedCount > 0) { - Alert.alert( - "✅ Turni Sincronizzati!", - `${savedCount} turni salvati nel calendario.` - ); - } else { - Alert.alert("Nessun orario trovato", `Errore estrazione. Date Trovate: ${dates.length}, Orari Trovati: ${shifts.length}. Assicurati di scansionare bene le colonne.`); - } - - } catch (e: any) { - console.error(e); - Alert.alert('Errore Calendario', 'Non è stato possibile salvare: ' + e.message); - } - }; - - const engineHtml = ` - - - - - - - - - - - `; - - return ( - - - - - - {/* Page Header */} - - Gestione Turni - Scansiona i turni dal tabellone e sincronizzali nel calendario. - - - - 📅 Sincronizzazione Calendario - - Seleziona gli screenshot del tuo tabellone orari. Il sistema li leggerà per cercare e salvare automaticamente i voli nel calendario del tuo telefono. - - - - - - 📷 Scansiona Screenshot Turni - - - - {imageList.length > 0 && ( - - {imageList.map((uri, index) => ( - - ))} - - )} - - {processing && ( - - - Estrazione del testo in corso... - - )} - - {ocrText ? ( - - Testo Estratto: - {ocrText} - - - ✅ Sincronizza nel Calendario! - - - ) : null} - - ); -} - -const styles = StyleSheet.create({ - container: { - flexGrow: 1, - backgroundColor: BG, - paddingBottom: 32, - }, - hiddenWebView: { height: 1, width: 1, opacity: 0, position: 'absolute', top: -100 }, - pageHeader: { - backgroundColor: '#fff', - paddingHorizontal: 16, paddingVertical: 14, - borderBottomWidth: 1, borderBottomColor: '#E5E7EB', - }, - pageTitle: { fontSize: 24, fontWeight: 'bold', color: DARK_BLUE }, - pageSub: { fontSize: 13, color: '#6B7280', marginTop: 4 }, - infoCard: { - backgroundColor: '#fff', - margin: 16, marginBottom: 0, - padding: 16, borderRadius: 14, - borderLeftWidth: 4, borderLeftColor: PRIMARY, - shadowColor: '#000', shadowOpacity: 0.05, shadowRadius: 6, elevation: 2, - }, - infoTitle: { fontWeight: 'bold', fontSize: 15, marginBottom: 8, color: PRIMARY }, - infoDesc: { fontSize: 13, color: '#6B7280', lineHeight: 20 }, - buttonsContainer: { margin: 16, marginBottom: 0 }, - button: { - backgroundColor: DARK_BLUE, - padding: 16, borderRadius: 14, - alignItems: 'center', - shadowColor: DARK_BLUE, shadowOpacity: 0.3, shadowRadius: 8, elevation: 5, - }, - buttonText: { color: '#fff', fontSize: 16, fontWeight: 'bold' }, - imagesPreview: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center', margin: 16, gap: 10 }, - image: { width: '45%', height: 140, resizeMode: 'cover', borderRadius: 10, borderWidth: 1, borderColor: '#E5E7EB' }, - loadingContainer: { marginTop: 24, alignItems: 'center' }, - loadingText: { marginTop: 10, color: '#6B7280', fontWeight: '500' }, - resultContainer: { - margin: 16, padding: 16, - backgroundColor: '#fff', borderRadius: 14, - shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 8, elevation: 3, - }, - resultTitle: { fontSize: 15, fontWeight: 'bold', color: DARK_BLUE, marginBottom: 10, borderBottomWidth: 1, borderBottomColor: '#E5E7EB', paddingBottom: 8 }, - resultText: { fontSize: 13, color: '#374151', lineHeight: 20, marginBottom: 16 }, - saveButton: { - backgroundColor: PRIMARY, - padding: 15, borderRadius: 12, alignItems: 'center', - shadowColor: PRIMARY, shadowOpacity: 0.3, shadowRadius: 6, elevation: 4, - }, - saveButtonText: { color: '#fff', fontSize: 15, fontWeight: 'bold' }, -}); - diff --git a/src/screens/TraveldocScreen.tsx b/src/screens/TraveldocScreen.tsx index 7b80a8f..b726737 100644 --- a/src/screens/TraveldocScreen.tsx +++ b/src/screens/TraveldocScreen.tsx @@ -3,9 +3,11 @@ import React, { useState, useEffect, useMemo } from 'react'; import { StyleSheet, View, Text, ActivityIndicator } from 'react-native'; import { WebView } from 'react-native-webview'; import { useAppTheme } from '../context/ThemeContext'; +import { useLanguage } from '../context/LanguageContext'; export default function TraveldocScreen() { const { colors } = useAppTheme(); + const { t } = useLanguage(); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(false); @@ -21,14 +23,14 @@ export default function TraveldocScreen() { {/* Header */} TravelDoc - Verifica documenti di viaggio + {t('traveldocSub')} {/* WebView */} {loading && ( - Caricamento TravelDoc… + {t('traveldocLoading')} )} {loadError && !loading && ( @@ -51,9 +53,9 @@ export default function TraveldocScreen() { } const styles = StyleSheet.create({ - header: { paddingHorizontal: 16, paddingVertical: 14, borderBottomWidth: 1 }, - title: { fontSize: 22, fontWeight: 'bold' }, - sub: { fontSize: 12, marginTop: 2 }, + header: { paddingHorizontal: 16, paddingTop: 16, paddingBottom: 14, borderBottomWidth: StyleSheet.hairlineWidth }, + title: { fontSize: 22, fontWeight: '700', letterSpacing: -0.3 }, + sub: { fontSize: 12, marginTop: 3, letterSpacing: 0.3 }, loadingWrap: { position: 'absolute', top: 60, left: 0, right: 0, bottom: 0, justifyContent: 'center', alignItems: 'center', zIndex: 10 }, loadingText: { marginTop: 12, fontSize: 14 }, }); diff --git a/src/utils/airlineOps.ts b/src/utils/airlineOps.ts index 7edf78a..744e003 100644 --- a/src/utils/airlineOps.ts +++ b/src/utils/airlineOps.ts @@ -36,3 +36,28 @@ export function getAirlineColor(name: string): HexColor { } export const ALLOWED_AIRLINES = ['wizz', 'aer lingus', 'easyjet', 'british airways', 'sas', 'scandinavian', 'flydubai']; + +// ─── Dynamic airline selection (persisted in AsyncStorage) ─────────────────── +import AsyncStorage from '@react-native-async-storage/async-storage'; +const SELECTED_AIRLINES_PREFIX = 'aerostaff_selected_airlines_v1_'; + +function airlineKey(airportCode: string) { + return `${SELECTED_AIRLINES_PREFIX}${airportCode.toUpperCase()}`; +} + +/** Read user-selected airlines for a specific airport. Falls back to ALLOWED_AIRLINES if never set. */ +export async function getSelectedAirlines(airportCode: string): Promise { + try { + const raw = await AsyncStorage.getItem(airlineKey(airportCode)); + if (raw) { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed) && parsed.length > 0) return parsed; + } + } catch {} + return ALLOWED_AIRLINES; +} + +/** Persist the user's airline selection for a specific airport (lowercase keys). */ +export async function setSelectedAirlines(airlines: string[], airportCode: string): Promise { + await AsyncStorage.setItem(airlineKey(airportCode), JSON.stringify(airlines)); +} diff --git a/src/utils/autoNotifications.ts b/src/utils/autoNotifications.ts index 4c44518..299e796 100644 --- a/src/utils/autoNotifications.ts +++ b/src/utils/autoNotifications.ts @@ -3,6 +3,12 @@ import * as Notifications from 'expo-notifications'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { getAirlineOps } from './airlineOps'; import { fetchAirportScheduleRaw } from './fr24api'; +import { getWritableCalendarId } from './shiftCalendar'; +import { + showShiftOngoingNotification, + dismissShiftOngoingNotification, + syncShiftOngoingExpiry, +} from './shiftOngoingNotification'; const NOTIF_IDS_KEY = 'aerostaff_notif_ids_v1'; const LAST_SCHEDULE_KEY = 'aerostaff_notif_last_schedule'; @@ -22,6 +28,9 @@ async function cancelPrevious() { */ export async function autoScheduleNotifications(): Promise { try { + // Dismiss ongoing shift notification if shift has ended + await syncShiftOngoingExpiry(); + // Skip if already scheduled today const todayKey = new Date().toISOString().split('T')[0]; const lastSchedule = await AsyncStorage.getItem(LAST_SCHEDULE_KEY); @@ -35,15 +44,17 @@ export async function autoScheduleNotifications(): Promise { const { status: calStatus } = await Calendar.requestCalendarPermissionsAsync(); if (calStatus !== 'granted') return 0; - const cals = await Calendar.getCalendarsAsync(Calendar.EntityTypes.EVENT); - const cal = cals.find(c => c.allowsModifications && c.isPrimary) || cals.find(c => c.allowsModifications); - if (!cal) return 0; + const calId = await getWritableCalendarId(); + if (!calId) return 0; const today = new Date(); today.setHours(0, 0, 0, 0); const endOfDay = new Date(today); endOfDay.setHours(23, 59, 59, 999); - const events = await Calendar.getEventsAsync([cal.id], today, endOfDay); + const events = await Calendar.getEventsAsync([calId], today, endOfDay); const shiftEvent = events.find(e => e.title.includes('Lavoro')); - if (!shiftEvent) return 0; + if (!shiftEvent) { + await dismissShiftOngoingNotification(); + return 0; + } const shiftStart = new Date(shiftEvent.startDate).getTime() / 1000; const shiftEnd = new Date(shiftEvent.endDate).getTime() / 1000; @@ -63,9 +74,39 @@ export async function autoScheduleNotifications(): Promise { return ts && ts >= shiftStart && ts <= shiftEnd; }); + // ── Persistent ongoing shift notification ────────────────────────────────── + const now = Date.now() / 1000; + const shiftStartDate = new Date(shiftStart * 1000); + const shiftEndDate = new Date(shiftEnd * 1000); + const fmt = (d: Date) => d.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }); + const shiftLabel = `${fmt(shiftStartDate)}–${fmt(shiftEndDate)}`; + + if (now >= shiftStart && now <= shiftEnd) { + const upcoming = shiftDepartures + .filter((f: any) => (f.flight?.time?.scheduled?.departure ?? 0) > now) + .sort((a: any, b: any) => + (a.flight?.time?.scheduled?.departure ?? 0) - (b.flight?.time?.scheduled?.departure ?? 0), + ); + const next = upcoming[0]; + let flightInfo: string; + if (next) { + const depTs = next.flight?.time?.scheduled?.departure as number; + const fn = next.flight?.identification?.number?.default ?? ''; + const dest = next.flight?.airport?.destination?.code?.iata ?? ''; + const time = new Date(depTs * 1000).toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }); + flightInfo = `Prossima: ${fn} ✈ ${dest} alle ${time} · ${shiftDepartures.length} voli oggi`; + } else { + flightInfo = `${shiftDepartures.length} voli · Nessuna partenza imminente`; + } + await showShiftOngoingNotification(shiftLabel, flightInfo, shiftEnd); + } else if (now > shiftEnd) { + await dismissShiftOngoingNotification(); + } + // ─────────────────────────────────────────────────────────────────────────── + + // Cancel old and schedule new await cancelPrevious(); - const now = Date.now() / 1000; const newIds: string[] = []; // ── Arrival notifications: 15 min before landing ── @@ -93,7 +134,7 @@ export async function autoScheduleNotifications(): Promise { }); newIds.push(id); } catch (err) { - console.error('Failed to schedule arrival notification:', err); + if (__DEV__) console.error('Failed to schedule arrival notification:', err); } } @@ -146,7 +187,7 @@ export async function autoScheduleNotifications(): Promise { newIds.push(id); } } catch (err) { - console.error('Failed to schedule departure notification:', err); + if (__DEV__) console.error('Failed to schedule departure notification:', err); } } @@ -170,7 +211,7 @@ export async function autoScheduleNotifications(): Promise { await AsyncStorage.setItem(LAST_SCHEDULE_KEY, todayKey); return newIds.length; } catch (e) { - console.error('autoScheduleNotifications error:', e); + if (__DEV__) console.error('autoScheduleNotifications error:', e); return 0; } } diff --git a/src/utils/devLog.ts b/src/utils/devLog.ts new file mode 100644 index 0000000..8ae1dae --- /dev/null +++ b/src/utils/devLog.ts @@ -0,0 +1,20 @@ +/** + * Development-only logging helpers. + * In production builds Metro eliminates the dead `if (false)` branches, + * so these calls compile away entirely — no performance cost in prod. + */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const devWarn = (...args: any[]): void => { + if (__DEV__) console.warn(...args); +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const devError = (...args: any[]): void => { + if (__DEV__) console.error(...args); +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const devLog = (...args: any[]): void => { + if (__DEV__) console.log(...args); +}; diff --git a/src/utils/fr24api.ts b/src/utils/fr24api.ts index a560d89..1252447 100644 --- a/src/utils/fr24api.ts +++ b/src/utils/fr24api.ts @@ -1,4 +1,4 @@ -import { ALLOWED_AIRLINES } from './airlineOps'; +import { getSelectedAirlines } from './airlineOps'; import { buildFr24ScheduleUrl, getAirportInfo, @@ -9,26 +9,85 @@ import { } from './airportSettings'; const FETCH_TIMEOUT = 10000; // 10 seconds +const CACHE_TTL = 120_000; // 2 minutes +// In-memory cache to avoid duplicate FR24 requests (e.g. FlightScreen + autoNotifications) +let _cache: { data: FR24ScheduleRaw; ts: number; code: string } | null = null; + +// ─── FR24 flight data types ────────────────────────────────────────────────── +export type FR24FlightTime = { + scheduled?: { departure?: number; arrival?: number }; + real?: { departure?: number; arrival?: number }; + estimated?: { departure?: number; arrival?: number }; +}; + +export type FR24AirportCode = { + iata?: string; + icao?: string; +}; + +export type FR24FlightAirport = { + code?: FR24AirportCode; + name?: string; + position?: { latitude?: number; longitude?: number }; +}; + +export type FR24FlightStatus = { + text?: string; + type?: string; + generic?: { status?: { text?: string; color?: string } }; +}; + +export type FR24FlightIdentification = { + number?: { default?: string; alternative?: string }; + callsign?: string; +}; + +export type FR24FlightAirline = { + name?: string; + code?: { iata?: string; icao?: string }; +}; + +export type FR24FlightAircraft = { + model?: { code?: string; text?: string }; + registration?: string; + hex?: string; +}; + +export type FR24FlightData = { + flight?: { + identification?: FR24FlightIdentification; + status?: FR24FlightStatus; + airline?: FR24FlightAirline; + airport?: { + origin?: FR24FlightAirport; + destination?: FR24FlightAirport; + }; + time?: FR24FlightTime; + aircraft?: FR24FlightAircraft; + }; +}; + +// ─── Schedule result types ─────────────────────────────────────────────────── export type FR24Schedule = { - arrivals: any[]; - departures: any[]; + arrivals: FR24FlightData[]; + departures: FR24FlightData[]; airportCode: string; airport: AirportInfo; }; export type FR24ScheduleRaw = { - allArrivals: any[]; - allDepartures: any[]; - arrivals: any[]; - departures: any[]; + allArrivals: FR24FlightData[]; + allDepartures: FR24FlightData[]; + arrivals: FR24FlightData[]; + departures: FR24FlightData[]; airportCode: string; airport: AirportInfo; }; -function filterAirlines(data: any[]) { +function filterAirlines(data: FR24FlightData[], selected: string[]) { return data.filter(item => - ALLOWED_AIRLINES.some(key => (item.flight?.airline?.name || '').toLowerCase().includes(key)), + selected.some(key => (item.flight?.airline?.name || '').toLowerCase().includes(key)), ); } @@ -47,6 +106,7 @@ export async function fetchAirportSchedule(code?: string): Promise try { const airportCode = await resolveAirportCode(code); + const selected = await getSelectedAirlines(airportCode); const res = await fetch(buildFr24ScheduleUrl(airportCode), { headers: { 'User-Agent': 'Mozilla/5.0' }, signal: controller.signal, @@ -57,8 +117,8 @@ export async function fetchAirportSchedule(code?: string): Promise const allDepartures = json.result?.response?.airport?.pluginData?.schedule?.departures?.data || []; return { - arrivals: filterAirlines(allArrivals), - departures: filterAirlines(allDepartures), + arrivals: filterAirlines(allArrivals, selected), + departures: filterAirlines(allDepartures, selected), airportCode, airport: getAirportInfo(airportCode), }; @@ -72,11 +132,23 @@ export async function fetchAirportSchedule(code?: string): Promise * (e.g. inbound arrival map by registration). */ export async function fetchAirportScheduleRaw(code?: string): Promise { + const airportCode = await resolveAirportCode(code); + const selected = await getSelectedAirlines(airportCode); + + // Return cached data if fresh enough + if (_cache && _cache.code === airportCode && Date.now() - _cache.ts < CACHE_TTL) { + // Re-filter with current selection (user may have changed airlines) + return { + ..._cache.data, + arrivals: filterAirlines(_cache.data.allArrivals, selected), + departures: filterAirlines(_cache.data.allDepartures, selected), + }; + } + const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT); try { - const airportCode = await resolveAirportCode(code); const res = await fetch(buildFr24ScheduleUrl(airportCode), { headers: { 'User-Agent': 'Mozilla/5.0' }, signal: controller.signal, @@ -86,14 +158,17 @@ export async function fetchAirportScheduleRaw(code?: string): Promise Number(raw)) { + await dismissShiftOngoingNotification(); + } + } catch {} +} diff --git a/src/utils/staffMonitor.ts b/src/utils/staffMonitor.ts new file mode 100644 index 0000000..9ccd73b --- /dev/null +++ b/src/utils/staffMonitor.ts @@ -0,0 +1,223 @@ +export type StaffMonitorFlight = { + flightNumber: string; + stand?: string; + checkin?: string; + gate?: string; + belt?: string; +}; + +type StaffMonitorCell = { + className: string; + text: string; +}; + +type HeaderColumns = { + flight?: number; + stand?: number; + checkin?: number; + gate?: number; + belt?: number; +}; + +type StaffMonitorColumnOffsets = { + stand?: number; + checkin?: number; + gate?: number; + belt?: number; +}; + +/** Normalize flight number: FR07146 → FR7146, FR00770 → FR770 */ +export function normalizeFlightNumber(raw: string): string { + const compact = raw.trim().toUpperCase().replace(/[^A-Z0-9]/g, ''); + return compact.replace(/^([A-Z]{2,3})0+([0-9])/, '$1$2'); +} + +function stripHTML(html: string): string { + return html + .replace(//gi, ' / ') + .replace(/<[^>]+>/g, '') + .replace(/°/g, '') + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/[^\x20-\x7E]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function extractTDCells(trHTML: string): StaffMonitorCell[] { + const cells: StaffMonitorCell[] = []; + const regex = /]*?)\/>|]*)>([\s\S]*?)<\/td>/gi; + let m: RegExpExecArray | null; + while ((m = regex.exec(trHTML)) !== null) { + const attrs = m[1] || m[2] || ''; + const classMatch = attrs.match(/class\s*=\s*["']([^"']+)["']/i); + cells.push({ + className: (classMatch?.[1] || '').trim(), + text: stripHTML(m[3] || ''), + }); + } + return cells; +} + +function getCellText(cells: StaffMonitorCell[], index: number): string | undefined { + const value = cells[index]?.text?.trim(); + return value ? value : undefined; +} + +function normalizeHeaderToken(value: string): string { + return value.toUpperCase().replace(/[^A-Z]/g, ''); +} + +function extractColumnsFromHeader(cells: StaffMonitorCell[]): HeaderColumns { + const columns: HeaderColumns = {}; + + for (let i = 0; i < cells.length; i++) { + const token = normalizeHeaderToken(cells[i].text); + if (!token) continue; + + if (token === 'VOLOFLIGHT' || token === 'FLIGHT' || token === 'VOLO') { + columns.flight = i; + continue; + } + if (token === 'STAND') { + columns.stand = i; + continue; + } + if (token === 'CHECKIN' || token === 'CHECKINDESK' || token === 'CHECKINDESKS') { + columns.checkin = i; + continue; + } + if (token === 'GATE') { + columns.gate = i; + continue; + } + if (token === 'BELT' || token === 'NASTROBELT' || token === 'NASTRO') { + columns.belt = i; + continue; + } + } + + return columns; +} + +function hasAtLeastOneColumn(columns: HeaderColumns, nature: 'D' | 'A'): boolean { + if (nature === 'D') { + return columns.stand !== undefined || columns.checkin !== undefined || columns.gate !== undefined; + } + return columns.stand !== undefined || columns.belt !== undefined; +} + +function toOffsets(columns: HeaderColumns): StaffMonitorColumnOffsets { + if (columns.flight === undefined) return {}; + const offsets: StaffMonitorColumnOffsets = {}; + if (columns.stand !== undefined) offsets.stand = columns.stand - columns.flight; + if (columns.checkin !== undefined) offsets.checkin = columns.checkin - columns.flight; + if (columns.gate !== undefined) offsets.gate = columns.gate - columns.flight; + if (columns.belt !== undefined) offsets.belt = columns.belt - columns.flight; + return offsets; +} + +function isPhoneOrJunk(value: string): boolean { + // Reject anything that looks like a phone number (8+ digits anywhere). + if ((value.match(/\d/g) || []).length >= 8) return true; + return false; +} + +function sanitizeStandGateBelt(value: string | undefined): string | undefined { + if (!value) return undefined; + const upper = value.toUpperCase().trim(); + if (!upper || isPhoneOrJunk(upper)) return undefined; + + const tokenWithDigits = upper.match(/\b[A-Z]*\d+[A-Z]*\b/); + if (tokenWithDigits) return tokenWithDigits[0]; + + const shortAlphaToken = upper.match(/\b[A-Z]\b/); + return shortAlphaToken ? shortAlphaToken[0] : undefined; +} + +function sanitizeCheckin(value: string | undefined): string | undefined { + if (!value) return undefined; + const clean = value + .toUpperCase() + .replace(/[^0-9A-Z\s\-/]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (!clean || isPhoneOrJunk(clean) || !/\d/.test(clean)) return undefined; + return clean; +} + +/** + * Fetch and parse stand/gate/check-in data from the Pisa Airport staffMonitor. + * + * We resolve columns from header labels when available, with an offset fallback + * from the flight-number cell for layout variants. + */ +export async function fetchStaffMonitorData(nature: 'D' | 'A'): Promise { + try { + const url = `https://servizi.pisa-airport.com/staffMonitor/staffMonitor?trans=true&nature=${nature}`; + const resp = await fetch(url); + if (!resp.ok) { + console.warn(`[staffMonitor] HTTP error for nature=${nature}: ${resp.status} ${resp.statusText}`); + return []; + } + const html = await resp.text(); + + const results: StaffMonitorFlight[] = []; + const trRegex = /]*>([\s\S]*?)<\/tr>/gi; + let match: RegExpExecArray | null; + let offsets: StaffMonitorColumnOffsets = {}; + + while ((match = trRegex.exec(html)) !== null) { + const rowHTML = match[1]; + + const cells = extractTDCells(rowHTML); + if (cells.length < 2) continue; + + const headerColumns = extractColumnsFromHeader(cells); + if (headerColumns.flight !== undefined || hasAtLeastOneColumn(headerColumns, nature)) { + offsets = { ...offsets, ...toOffsets(headerColumns) }; + } + + const flightCellIndexFromClass = cells.findIndex(cell => /\bclsFlight\b/i.test(cell.className)); + const flightCellIndex = flightCellIndexFromClass; + if (flightCellIndex === -1) continue; + + const rawFlight = getCellText(cells, flightCellIndex); + if (!rawFlight) continue; + + const flightNumber = normalizeFlightNumber(rawFlight); + if (!flightNumber || flightNumber === 'VOLOFLIGHT') continue; + + if (nature === 'D') { + const standIdx = flightCellIndex + (offsets.stand ?? 11); + const checkinIdx = flightCellIndex + (offsets.checkin ?? 12); + const gateIdx = flightCellIndex + (offsets.gate ?? 13); + results.push({ + flightNumber, + stand: sanitizeStandGateBelt(getCellText(cells, standIdx)), + checkin: sanitizeCheckin(getCellText(cells, checkinIdx)), + gate: sanitizeStandGateBelt(getCellText(cells, gateIdx)), + }); + } else { + const standIdx = flightCellIndex + (offsets.stand ?? 10); + const beltIdx = flightCellIndex + (offsets.belt ?? 11); + results.push({ + flightNumber, + stand: sanitizeStandGateBelt(getCellText(cells, standIdx)), + belt: sanitizeStandGateBelt(getCellText(cells, beltIdx)), + }); + } + } + + if (__DEV__) { + console.log(`[staffMonitor] nature=${nature} parsed ${results.length} flights.`, results.slice(0, 5).map(r => r.flightNumber)); + } + + return results; + } catch (e) { + console.error(`[staffMonitor] fetch/parse error for nature=${nature}:`, e); + return []; + } +} diff --git a/src/widgets/ShiftWidget.tsx b/src/widgets/ShiftWidget.tsx index b0b1554..1c61e02 100644 --- a/src/widgets/ShiftWidget.tsx +++ b/src/widgets/ShiftWidget.tsx @@ -2,16 +2,18 @@ import React from 'react'; import { FlexWidget, TextWidget, ListWidget } from 'react-native-android-widget'; import type { WidgetData, WidgetFlight } from './widgetTaskHandler'; -const BG = '#0F172A'; -const HEADER_BG = '#1E293B'; -const TEXT = '#F1F5F9'; -const MUTED = '#94A3B8'; -const ORANGE = '#F59E0B'; -const BLUE = '#3B82F6'; - -const PILL_RADIUS = 10; -const ORANGE_BG = '#3D2800'; // dark amber for CI pill background -const BLUE_BG = '#0C1F3D'; // dark blue for Gate pill background +// ── Brand colours ───────────────────────────────────────────────────────────── +const BG = '#120700'; // deep warm dark +const HEADER_BG = '#1E0E02'; // slightly lighter warm dark +const CARD_ODD = '#1E0E02'; +const CARD_EVEN = '#160900'; +const TEXT = '#FFF5EE'; // warm white +const MUTED = '#A07850'; // warm muted +const ORANGE = '#F47B16'; // app primary +const ORANGE_DARK = '#3A1800'; // pill backgrounds +const BLUE = '#60A5FA'; // gate accent (kept complementary) +const BLUE_BG = '#0C1830'; +const PILL_R = 10; function FlightRow({ flight, index }: { flight: WidgetFlight; index: number }) { const pinned = flight.isPinned === true; @@ -20,14 +22,14 @@ function FlightRow({ flight, index }: { flight: WidgetFlight; index: number }) { style={{ width: 'match_parent', paddingVertical: 8, - paddingHorizontal: 10, - backgroundColor: pinned ? '#3D2800' : (index % 2 === 0 ? '#1E293B' : '#162032'), + paddingHorizontal: 12, + backgroundColor: pinned ? '#2A1000' : (index % 2 === 0 ? CARD_ODD : CARD_EVEN), flexDirection: 'column', ...(pinned ? { borderLeftWidth: 3, borderLeftColor: ORANGE } : {}), }} clickAction="OPEN_APP" > - {/* Top row: airline pill + destination pill + departure time */} + {/* Top row: flight pill + destination + time */} - {/* Flight number pill with airline color */} + {/* Flight number pill */} @@ -81,8 +83,8 @@ function FlightRow({ flight, index }: { flight: WidgetFlight; index: number }) { {/* CI pill */} + {/* Orange accent bar */} + + + + + + + ); +} + +function Footer({ updatedAt }: { updatedAt: string }) { + return ( + + + + + ); +} + +// ── Root widget ─────────────────────────────────────────────────────────────── export function ShiftWidget({ data }: { data: WidgetData }) { + // ── Rest day ── if (data.state === 'rest') { return ( @@ -132,15 +207,20 @@ export function ShiftWidget({ data }: { data: WidgetData }) { style={{ height: 'match_parent', width: 'match_parent', backgroundColor: BG, borderRadius: 20, - justifyContent: 'center', alignItems: 'center', flexDirection: 'column', + flexDirection: 'column', overflow: 'hidden', }} clickAction="OPEN_APP" > - - + + + + + ); } @@ -152,14 +232,19 @@ export function ShiftWidget({ data }: { data: WidgetData }) { style={{ height: 'match_parent', width: 'match_parent', backgroundColor: BG, borderRadius: 20, - justifyContent: 'center', alignItems: 'center', flexDirection: 'column', + flexDirection: 'column', overflow: 'hidden', }} clickAction="OPEN_APP" > - + + + + ); } @@ -171,18 +256,23 @@ export function ShiftWidget({ data }: { data: WidgetData }) { style={{ height: 'match_parent', width: 'match_parent', backgroundColor: BG, borderRadius: 20, - justifyContent: 'center', alignItems: 'center', flexDirection: 'column', + flexDirection: 'column', overflow: 'hidden', }} clickAction="REFRESH" > - - + + + + + ); } @@ -198,35 +288,13 @@ export function ShiftWidget({ data }: { data: WidgetData }) { }} clickAction="OPEN_APP" > - - - +
- - - +